Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions bofhound/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
43 changes: 42 additions & 1 deletion bofhound/ad/adds.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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] = []
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
3 changes: 2 additions & 1 deletion bofhound/ad/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@
from .bloodhound_issuancepolicy import BloodHoundIssuancePolicy
from .bloodhound_certtemplate import BloodHoundCertTemplate
from .bloodhound_domaintrust import BloodHoundDomainTrust
from .bloodhound_crossref import BloodHoundCrossRef
from .bloodhound_crossref import BloodHoundCrossRef
from .bloodhound_dnsnode import BloodHoundDnsNode
2 changes: 2 additions & 0 deletions bofhound/ad/models/bloodhound_computer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
50 changes: 50 additions & 0 deletions bofhound/ad/models/bloodhound_dnsnode.py
Original file line number Diff line number Diff line change
@@ -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)

1 change: 1 addition & 0 deletions bofhound/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class ColorScheme:
ou = "[dark_orange]"
containers = "[orange]"
gpo = "[purple]"
dns = "[dark_magenta]"

OBJ_EXTRA_FMT = {
"markup": True,
Expand Down
20 changes: 18 additions & 2 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion tests/ad/test_adds.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down