diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b42db3..22e3f17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ # Changelog +## [0.4.21] - 1/14/2026 +### Added +- Add ability to parse dnsNode objects and add IP addresses to computer properties by matching on dNSHostName [#51](https://github.com/coffeegist/bofhound/pull/51) + ## [0.4.20] - 12/16/2025 ### Fixes - Fix [#46](https://github.com/coffeegist/bofhound/issues/46) which caused well-known SIDs (groups) to be mising from bofhound output diff --git a/bofhound/__main__.py b/bofhound/__main__.py index c1dab1c..3e304d1 100644 --- a/bofhound/__main__.py +++ b/bofhound/__main__.py @@ -153,6 +153,7 @@ def main( logger.info("Parsed %d Cert Templates", len(ad.certtemplates)) logger.info("Parsed %d Schemas", len(ad.schemas)) logger.info("Parsed %d Referrals", len(ad.CROSSREF_MAP)) + logger.info("Parsed %d DNS nodes", len(ad.DNSNODE_MAP)) logger.info("Parsed %d Unknown Objects", len(ad.unknown_objects)) logger.info("Parsed %d Sessions", len(broker.sessions)) logger.info("Parsed %d Privileged Sessions", len(broker.privileged_sessions)) diff --git a/bofhound/ad/adds.py b/bofhound/ad/adds.py index 07c244c..22f9a7d 100644 --- a/bofhound/ad/adds.py +++ b/bofhound/ad/adds.py @@ -13,7 +13,7 @@ BloodHoundComputer, BloodHoundDomain, BloodHoundGroup, BloodHoundObject, BloodHoundSchema, BloodHoundUser, BloodHoundOU, BloodHoundGPO, BloodHoundEnterpriseCA, BloodHoundAIACA, BloodHoundRootCA, BloodHoundNTAuthStore, BloodHoundIssuancePolicy, BloodHoundCertTemplate, - BloodHoundContainer, BloodHoundDomainTrust, BloodHoundCrossRef + BloodHoundContainer, BloodHoundDomainTrust, BloodHoundCrossRef, BloodHoundDnsNode ) from bofhound.logger import OBJ_EXTRA_FMT, ColorScheme from bofhound import console @@ -45,6 +45,7 @@ def __init__(self): self.DN_MAP = {} # {dn: BofHoundModel} self.DOMAIN_MAP = {} # {dc: ObjectIdentifier} self.CROSSREF_MAP = {} # { netBiosName: BofHoundModel } + self.DNSNODE_MAP = {} # { dnsHostname: set(ipaddress) } self.ObjectTypeGuidMap = {} # { Name : schemaIdGuid } self.domains: list[BloodHoundDomain] = [] self.users: list[BloodHoundUser] = [] @@ -90,6 +91,15 @@ def import_objects(self, objects): self.CROSSREF_MAP[new_crossref.netBiosName] = new_crossref continue + # check if object is a dnsNode - exception for normally required attributes + if 'top, dnsNode' in object.get(ADDS.AT_OBJECTCLASS, ''): + new_dnsnode = BloodHoundDnsNode(object) + if new_dnsnode.name is not None and new_dnsnode.ipaddresses: + if new_dnsnode.name not in self.DNSNODE_MAP: + self.DNSNODE_MAP[new_dnsnode.name] = set() + self.DNSNODE_MAP[new_dnsnode.name].update(new_dnsnode.ipaddresses) + continue + # # if samaccounttype comes back as something other # than int, skip the object @@ -384,6 +394,37 @@ def process(self): self.resolve_hosting_computer(ca) logger.info("Resolved hosting computers of CAs") + with console.status(" [bold] Assigning IP addresses to computers", spinner="aesthetic"): + for host_fqdn in self.DNSNODE_MAP: + computer_found = False + + # try to find computer object based on its dNSHostName attribute + for computer in self.computers: + if computer.matches_dnshostname(host_fqdn): + computer_found = True + break + + # look for a computer with samaccountname host$ and the domain's sid + if not computer_found: + host_parts = host_fqdn.split(".") + host_name = host_parts[0] + host_domain = ".".join(host_parts[1:]) + dc = BloodHoundObject.get_dn(host_domain.upper()) + domain_sid = self.DOMAIN_MAP.get(dc, None) + + if domain_sid is not None: + for computer in self.computers: + if computer.matches_samaccountname(host_name) and computer.ObjectIdentifier.startswith(domain_sid): + computer_found = True + break + + if not computer_found: + continue + + computer.ipaddresses = list(self.DNSNODE_MAP[host_fqdn]) + + logger.info("Assigned IP addresses to computers") + def get_sid_from_name(self, name): for entry in self.SID_MAP: if(self.SID_MAP[entry].Properties["name"].lower() == name): diff --git a/bofhound/ad/models/__init__.py b/bofhound/ad/models/__init__.py index ab09448..8d3fb55 100644 --- a/bofhound/ad/models/__init__.py +++ b/bofhound/ad/models/__init__.py @@ -14,4 +14,5 @@ from .bloodhound_issuancepolicy import BloodHoundIssuancePolicy from .bloodhound_certtemplate import BloodHoundCertTemplate from .bloodhound_domaintrust import BloodHoundDomainTrust -from .bloodhound_crossref import BloodHoundCrossRef \ No newline at end of file +from .bloodhound_crossref import BloodHoundCrossRef +from .bloodhound_dnsnode import BloodHoundDnsNode diff --git a/bofhound/ad/models/bloodhound_computer.py b/bofhound/ad/models/bloodhound_computer.py index a852b5b..b3f7042 100644 --- a/bofhound/ad/models/bloodhound_computer.py +++ b/bofhound/ad/models/bloodhound_computer.py @@ -51,6 +51,7 @@ def __init__(self, object): self.privileged_sessions = [] self.registry_sessions = [] self.local_group_members = {} # {group_name: [{member_sid, member_type}]} + self.ipaddresses = [] if 'dnshostname' in object.keys(): self.hostname = object.get('dnshostname', None) @@ -146,6 +147,7 @@ def __init__(self, object): def to_json(self, properties_level): self.Properties['isaclprotected'] = self.IsACLProtected data = super().to_json(properties_level) + data["Properties"]["ipaddresses"] = self.ipaddresses data["Sessions"] = self.format_session_json(self.sessions) data["PrivilegedSessions"] = self.format_session_json(self.privileged_sessions) data["RegistrySessions"] = self.format_session_json(self.registry_sessions) diff --git a/bofhound/ad/models/bloodhound_dnsnode.py b/bofhound/ad/models/bloodhound_dnsnode.py new file mode 100644 index 0000000..cff1f2d --- /dev/null +++ b/bofhound/ad/models/bloodhound_dnsnode.py @@ -0,0 +1,50 @@ +from adidnsdump import dnsdump +from bofhound.logger import logger, OBJ_EXTRA_FMT, ColorScheme +import base64 + +class BloodHoundDnsNode(object): + + def __init__(self, object): + self.distinguishedName = None + self.ipaddresses = [] + self.name = None + + if 'dnsrecord' in object.keys() and 'name' in object.keys() and 'distinguishedname' in object.keys(): + dn = object.get('distinguishedname') + self.distinguishedName = dn.upper() + dn = dn.split(',') + domain_name = dn[0].split('=')[1] + domain_suffix = dn[1].split('=')[1] + + if domain_name in ['@', 'DomainDnsZones', 'ForestDnsZones'] or domain_suffix in ['RootDNSServers', '..TrustAnchors']: + logger.debug(f"Ignoring dnsNode object {ColorScheme.dns}{self.distinguishedName}[/]", extra=OBJ_EXTRA_FMT) + return + + if domain_suffix.endswith(".in-addr.arpa"): + addr = domain_suffix.rstrip(".in-addr.arpa").split(".") + addr.insert(0, object.get('name')) + addr.reverse() + self.ipaddresses.append('.'.join(addr)) + else: + self.name = (object.get('name') + '.' + domain_suffix).lower() + + for b64dr in object.get('dnsrecord').split(', '): + dr = dnsdump.DNS_RECORD(base64.b64decode(b64dr)) + if dr['Type'] == 1: # A + address = dnsdump.DNS_RPC_RECORD_A(dr['Data']) + self.ipaddresses.append(address.formatCanonical()) + elif dr['Type'] == 28: # AAAA + address = dnsdump.DNS_RPC_RECORD_AAAA(dr['Data']) + self.ipaddresses.append(address.formatCanonical()) + elif dr['Type'] == 12: # PTR + address = dnsdump.DNS_RPC_RECORD_NODE_NAME(dr['Data']) + name = address[list(address.fields)[0]].toFqdn().rstrip('.') + if len(name.split('.')) == 1: + # not really correct, but useful first (stateless) approximization + self.name = '.'.join([name, dn[-2].split('=')[1], dn[-1].split('=')[1]]).lower() + else: + self.name = name.lower() + + if self.ipaddresses: + logger.debug(f"Parsed dnsNode object {ColorScheme.dns}{self.distinguishedName}[/] {self.name} = {','.join(self.ipaddresses)}", extra=OBJ_EXTRA_FMT) + diff --git a/bofhound/logger.py b/bofhound/logger.py index 1a5271a..0d49a50 100644 --- a/bofhound/logger.py +++ b/bofhound/logger.py @@ -14,6 +14,7 @@ class ColorScheme: ou = "[dark_orange]" containers = "[orange]" gpo = "[purple]" + dns = "[dark_magenta]" OBJ_EXTRA_FMT = { "markup": True, diff --git a/poetry.lock b/poetry.lock index 404095a..82ba7f9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,20 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "adidnsdump" +version = "1.4.0" +description = "Active Directory Integrated DNS dumping by any authenticated user" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "adidnsdump-1.4.0-py3-none-any.whl", hash = "sha256:2193705ad6fb6b5446f3816d7c035fc09c744c2b802a4d3f340a9db0c5534fad"}, + {file = "adidnsdump-1.4.0.tar.gz", hash = "sha256:86bfca4e837ba761d73f12ec4e81debd2e2cfaa6558984d840b493d502e695c3"}, +] + +[package.dependencies] +impacket = "*" +ldap3 = ">2.5.0,<2.5.2 || >2.5.2,<2.6 || >2.6" [[package]] name = "aiohappyeyeballs" @@ -2987,4 +3003,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "0aefde2e0647d0ca8e5d163b3af324c32632e9b0c3138c8402f252a1de8c5bf6" +content-hash = "759df765be393bf56a6460cba320ccadb4f8f97d7ae724b2259531d4f8f1d708" diff --git a/pyproject.toml b/pyproject.toml index c17221d..97d92ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bofhound" -version = "0.4.20" +version = "0.4.21" description = "Parse output from common sources and transform it into BloodHound-ingestible data" authors = [ "Adam Brown", @@ -39,6 +39,7 @@ cffi = "^1.17.1" mythic = "^0.2.5" syncer = "^2.0.3" requests = "^2.32.3" +adidnsdump = "^1.4.0" [tool.poetry.group.dev.dependencies] pylint = "^2.13" diff --git a/tests/ad/test_adds.py b/tests/ad/test_adds.py index ba7db2b..5eb7c17 100644 --- a/tests/ad/test_adds.py +++ b/tests/ad/test_adds.py @@ -139,7 +139,8 @@ def test_import_objects_expectedValuesFromStandardDataSet(testdata_ldapsearchbof assert len(adds.ous) == 1 assert len(adds.gpos) == 4 assert len(adds.containers) == 24 - assert len(adds.unknown_objects) == 36 + assert len(adds.DNSNODE_MAP) == 0 # 14 dnsNode objects exist, but are tossed due to missing dnsRecond attr + assert len(adds.unknown_objects) == 22 def test_import_objects_MinimalObject(raw_user):