Skip to content

Commit

Permalink
Merge pull request #4 from coffeegist/feature/parse-session-data
Browse files Browse the repository at this point in the history
  • Loading branch information
coffeegist authored Jan 12, 2024
2 parents a05a761 + 5e1ee0b commit ee84bdf
Show file tree
Hide file tree
Showing 37 changed files with 16,573 additions and 42 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
# Changelog
## [0.3.0] - 12/27/2023
### Added
- ADDS model for AD crossRef objects (referrals)
- Models for Local objects (sessions and local group memberships)
- Parsers for registry sessions, privileged sessions, sessions and local group memberships
- ADDS processing logic to tie local group/session data to a computer object

## [0.2.1] - 08/09/2023
### Changed
- Updated output JSON to v5 (BloodHound CE) specs
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
| |_) | | `--' | | | | | | | | `--' | | `--' | | |\ | | '--' |
|______/ \______/ |__| |__| |___\_\________\_\________\|__| \___\|_________\
by Fortalice ✪
<< @coffeegist | @Tw1sm >>
```

# BOFHound
Expand All @@ -15,6 +15,10 @@ BOFHound is an offline BloodHound ingestor and LDAP result parser compatible wit

By parsing log files generated by the aforementioned tools, BOFHound allows operators to utilize BloodHound's beloved interface while maintaining full control over the LDAP queries being run and the spped at which they are executed. This leaves room for operator discretion to account for potential honeypot accounts, expensive LDAP query thresholds and other detection mechanisms designed with the traditional, automated BloodHound collectors in mind.

Check out the [dedicated BOF repository](https://github.com/Tw1sm/bofhound-bof-kit) for BOFs that gather local group and session data for BOFHound parsing

### Related Blogs

[Blog - Granularize Your AD Recon Game](https://www.fortalicesolutions.com/posts/bofhound-granularize-your-active-directory-reconnaissance-game)

[Blog - Granularize Your AD Recon Game Part 2](https://www.fortalicesolutions.com/posts/granularize-your-active-directory-reconnaissance-game-part-2)
Expand Down Expand Up @@ -69,8 +73,14 @@ Retrieve Only the ms-Mcs-AdmPwd schemaIDGUID
ldapsearch (name=ms-mcs-admpwd) name,schemaidguid 1 "" CN=Schema,CN=Configuration,DC=windomain,DC=local
```

Retrieve Domain NetBIOS Names (useful if collecting data via `bofhound-netloggedon/netsession` BOFs)
```
ldapsearch (netbiosname=*) * 0 "" "CN=Partitions,CN=Configuration,DC=windomain,DC=local"
```

# Versions
Check the tagged releases to download a specific version
- v0.3.0 and onward support session/local group data
- v0.2.1 and onward are compatible with BloodHound CE
- v0.2.0 is the last release supporting BloodHound Legacy

Expand Down
26 changes: 19 additions & 7 deletions bofhound/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
import typer
import glob
import os
from bofhound.parsers import LdapSearchBofParser, Brc4LdapSentinelParser
from bofhound.parsers import LdapSearchBofParser, Brc4LdapSentinelParser, GenericParser
from bofhound.writer import BloodHoundWriter
from bofhound.ad import ADDS
from bofhound.local import LocalBroker
from bofhound import console

app = typer.Typer(
Expand Down Expand Up @@ -67,22 +68,27 @@ def main(
logging.debug('Using Brute Ratel parser')
parser = Brc4LdapSentinelParser

parsed_objects = []
parsed_ldap_objects = []
parsed_local_objects = []
with console.status(f"", spinner="aesthetic") as status:
for log in cs_logs:
status.update(f" [bold] Parsing {log}")
new_objects = parser.parse_file(log)
new_local_objects = GenericParser.parse_file(log)
logging.debug(f"Parsed {log}")
logging.debug(f"Found {len(new_objects)} objects in {log}")
parsed_objects.extend(new_objects)
parsed_ldap_objects.extend(new_objects)
parsed_local_objects.extend(new_local_objects)


logging.info(f"Parsed {len(parsed_objects)} objects from {len(cs_logs)} log files")
logging.info(f"Parsed {len(parsed_ldap_objects)} LDAP objects from {len(cs_logs)} log files")
logging.info(f"Parsed {len(parsed_local_objects)} local group/session objects from {len(cs_logs)} log files")

ad = ADDS()
broker = LocalBroker()

logging.info("Sorting parsed objects by type...")
ad.import_objects(parsed_objects)
ad.import_objects(parsed_ldap_objects)
broker.import_objects(parsed_local_objects, ad.DOMAIN_MAP.values())

logging.info(f"Parsed {len(ad.users)} Users")
logging.info(f"Parsed {len(ad.groups)} Groups")
Expand All @@ -92,9 +98,15 @@ def main(
logging.info(f"Parsed {len(ad.ous)} OUs")
logging.info(f"Parsed {len(ad.gpos)} GPOs")
logging.info(f"Parsed {len(ad.schemas)} Schemas")
logging.info(f"Parsed {len(ad.CROSSREF_MAP)} Referrals")
logging.info(f"Parsed {len(ad.unknown_objects)} Unknown Objects")
logging.info(f"Parsed {len(broker.sessions)} Sessions")
logging.info(f"Parsed {len(broker.privileged_sessions)} Privileged Sessions")
logging.info(f"Parsed {len(broker.registry_sessions)} Registry Sessions")
logging.info(f"Parsed {len(broker.local_group_memberships)} Local Group Memberships")

ad.process()
ad.process_local_objects(broker)

BloodHoundWriter.write(
output_folder,
Expand All @@ -118,7 +130,7 @@ def banner():
| |_) | | `--' | | | | | | | | `--' | | `--' | | |\ | | '--' |
|______/ \\______/ |__| |__| |___\\_\\________\\_\\________\\|__| \\___\\|_________\\
by Fortalice ✪
<< @coffeegist | @Tw1sm >>
''')


Expand Down
214 changes: 213 additions & 1 deletion bofhound/ad/adds.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from io import BytesIO
from bloodhound.ad.utils import ADUtils
from bloodhound.enumeration.acls import SecurityDescriptor, ACL, ACCESS_ALLOWED_ACE, ACCESS_MASK, ACE, ACCESS_ALLOWED_OBJECT_ACE, has_extended_right, EXTRIGHTS_GUID_MAPPING, can_write_property, ace_applies
from bofhound.ad.models import BloodHoundComputer, BloodHoundDomain, BloodHoundGroup, BloodHoundObject, BloodHoundSchema, BloodHoundUser, BloodHoundOU, BloodHoundGPO, BloodHoundDomainTrust
from bofhound.ad.models import BloodHoundComputer, BloodHoundDomain, BloodHoundGroup, BloodHoundObject, BloodHoundSchema, BloodHoundUser, BloodHoundOU, BloodHoundGPO, BloodHoundDomainTrust, BloodHoundCrossRef
from bofhound.logger import OBJ_EXTRA_FMT, ColorScheme
from bofhound import console

Expand All @@ -27,6 +27,7 @@ def __init__(self):
self.SID_MAP = {} # {sid: BofHoundModel}
self.DN_MAP = {} # {dn: BofHoundModel}
self.DOMAIN_MAP = {} # {dc: ObjectIdentifier}
self.CROSSREF_MAP = {} # { netBiosName: BofHoundModel }
self.ObjectTypeGuidMap = {} # { Name : schemaIdGuid }
self.domains = []
self.users = []
Expand All @@ -48,6 +49,7 @@ def import_objects(self, objects):
"""

for object in objects:
# check if object is a schema - exception for normally required attributes
schemaIdGuid = object.get(ADDS.AT_SCHEMAIDGUID, None)
if schemaIdGuid:
new_schema = BloodHoundSchema(object)
Expand All @@ -56,6 +58,14 @@ def import_objects(self, objects):
if new_schema.Name not in self.ObjectTypeGuidMap.keys():
self.ObjectTypeGuidMap[new_schema.Name] = new_schema.SchemaIdGuid
continue

# check if object is a crossRef - exception for normally required attributes
if 'top, crossRef' in object.get(ADDS.AT_OBJECTCLASS, ''):
new_crossref = BloodHoundCrossRef(object)
if new_crossref.netBiosName is not None:
if new_crossref.netBiosName not in self.CROSSREF_MAP.keys():
self.CROSSREF_MAP[new_crossref.netBiosName] = new_crossref
continue

accountType = int(object.get(ADDS.AT_SAMACCOUNTTYPE, 0))
target_list = None
Expand Down Expand Up @@ -724,3 +734,205 @@ def _lookup_known_sid(self, object, sid):
target_list = self.groups
bhObject.Properties["name"] = ADUtils.WELLKNOWN_SIDS[sid][0].upper()
return bhObject, target_list


def _get_domain_sid_from_netbios_name(self, nbtns_domain):
if nbtns_domain in self.CROSSREF_MAP.keys():
dn = self.CROSSREF_MAP[nbtns_domain].distinguishedName
if dn in self.DOMAIN_MAP.keys():
return self.DOMAIN_MAP[dn]
return None


# process local group memberships and sessions
def process_local_objects(self, broker):
for computer in self.computers:
self.process_privileged_sessions(broker.privileged_sessions, computer)
self.process_registry_sessions(broker.registry_sessions, computer)
self.process_sessions(broker.sessions, computer)
self.process_local_group_memberships(broker.local_group_memberships, computer)


if len(broker.local_group_memberships) > 0:
logging.info(f"Resolved local group memberships")

if len(broker.privileged_sessions) > 0 \
or len(broker.registry_sessions) > 0 \
or len(broker.sessions) > 0:

logging.info(f"Resolved sessions")


# correlate privileged sessions to BH Computer objects
def process_privileged_sessions(self, privileged_sessions, computer_object):
for session in privileged_sessions:
# skip sessions that have already been matched to a computer object
if session.matched:
continue

computer_found = False

# first we'll try to directly match the session host's dns name to a
# computer object's dNSHostName attribute
if session.host_fqdn is not None:
if computer_object.matches_dnshostname(session.host_fqdn):
computer_found = True

# second we'll check to see if the host's DNS domain is a known domain
# converting the host DNS suffix to a domain component could be problematic?
if session.host_domain is not None and not computer_found:
dc = BloodHoundObject.get_dn(session.host_domain.upper())
domain_sid = self.DOMAIN_MAP.get(dc, None)

# if we have the domain, check for a computer with samaccountname host$
# and the domain's sid
if domain_sid is not None:
if computer_object.matches_samaccountname(session.host_name) and \
computer_object.ObjectIdentifier.startswith(domain_sid):

computer_found = True

# if we've got the computer, then try to find the user's SID
if not computer_found:
continue

match_users = [user for user in self.users if user.Properties.get('samaccountname', '').lower() == session.user.lower()]
if len(match_users) > 1:
logging.warning(f"Multiple users with sAMAccountName {ColorScheme.user}{session.user}[/] found for privileged session")
# TODO: implement NetBIOS domain name handling
continue
elif len(match_users) == 1:
user_sid = match_users[0].ObjectIdentifier
computer_object.add_session(user_sid, "privileged")
logging.debug(f"Resolved privileged session on {ColorScheme.computer}{computer_object.Properties['name']}[/]", extra=OBJ_EXTRA_FMT)


# correlate registry sessions to BH Computer objects
def process_registry_sessions(self, registry_sessions, computer_object):
for session in registry_sessions:
# skip sessions that have already been matched to a computer object
if session.matched:
continue

# first we'll try to directly match the session host's dns name to a
# computer object's dNSHostName attribute
if session.host_fqdn is not None:
if computer_object.matches_dnshostname(session.host_name):
session.matched = True
computer_object.add_session(session.user_sid, "registry")
logging.debug(f"Resolved registry session on {ColorScheme.computer}{computer_object.Properties['name']}[/] via dNSHostName match", extra=OBJ_EXTRA_FMT)
continue

# second we'll check to see if the host's DNS domain is a known domain
# converting the host DNS suffix to a domain component could be problematic?
if session.host_domain is not None:
dc = BloodHoundObject.get_dn(session.host_domain.upper())
domain_sid = self.DOMAIN_MAP.get(dc, None)

# if we have the domain, check for a computer with samaccountname host$
# and the domain's sid
if domain_sid is not None:
if computer_object.matches_samaccountname(session.host_name) and \
computer_object.ObjectIdentifier.startswith(domain_sid):

session.matched = True
computer_object.add_session(session.user_sid, "registry")
logging.debug(f"Resolved registry session on {ColorScheme.computer}{computer_object.Properties['name']}[/] via domain + sAMAccountName match", extra=OBJ_EXTRA_FMT)
continue

# if we don't have the host domain/FQDN from the session, we just try to match samaccountname
# this is probably only error prone if there multiple domains with the same hostname
elif computer_object.matches_samaccountname(session.host_name):
session.matched = True
computer_object.add_session(session.user_sid, "registry")
logging.debug(f"Resolved registry session on {ColorScheme.computer}{computer_object.Properties['name']}[/] via fuzzy sAMAccountName match", extra=OBJ_EXTRA_FMT)


# correlate sessions to BH Computer objects
def process_sessions(self, sessions, computer_object):
for session in sessions:
# skip sessions that have already been matched to a computer object
if session.matched:
continue

computer_found = False

# case 1: we have the host's DNS name
if session.ptr_record is not None:

# first try to match dNSHostName
if computer_object.matches_dnshostname(session.ptr_record):
computer_found = True

# if that doesn't work, try to match the host's domain
if session.computer_domain is not None and not computer_found:
dc = BloodHoundObject.get_dn(session.computer_domain.upper())
domain_sid = self.DOMAIN_MAP.get(dc, None)

# if we have the domain, check for a computer with samaccountname host$
# and the domain's sid
if domain_sid is not None:
if computer_object.matches_samaccountname(session.computer_name) and \
computer_object.ObjectIdentifier.startswith(domain_sid):

computer_found = True

# case 2: we have the NETBIOS host and domain name
elif session.computer_netbios_domain is not None:
domain_sid = self._get_domain_sid_from_netbios_name(session.computer_netbios_domain)
if domain_sid is not None:
if computer_object.matches_samaccountname(session.computer_name) and computer_object.ObjectIdentifier.startswith(domain_sid):
computer_found = True

# if we've got the computer, then try to find the user's SID
if not computer_found:
continue

match_users = [user for user in self.users if user.Properties.get('samaccountname', '').lower() == session.username.lower()]
if len(match_users) > 1:
logging.warning(f"Multiple users with sAMAccountName {ColorScheme.user}{session.user}[/] found for session")
# TODO: implement NetBIOS domain name handling
continue
elif len(match_users) == 1:
user_sid = match_users[0].ObjectIdentifier
computer_object.add_session(user_sid, "session")
logging.debug(f"Resolved session on {ColorScheme.computer}{computer_object.Properties['name']}[/]", extra=OBJ_EXTRA_FMT)


# correlate local group memberships to BH Computer objects
def process_local_group_memberships(self, local_group_memberships, computer_object):
for member in local_group_memberships:
# skip memberships that have already been matched to a computer object
if member.matched:
continue

computer_found = False

# first we'll try to directly match the session host's dns name to a
# computer object's dNSHostName attribute
if member.host_fqdn is not None:
if computer_object.matches_dnshostname(member.host_fqdn):
computer_found = True


# second we'll check to see if the host's DNS domain is a known domain
if member.host_domain is not None and not computer_found:
dc = BloodHoundObject.get_dn(member.host_domain.upper())
domain_sid = self.DOMAIN_MAP.get(dc, None)

# if we have the domain, check for a computer with samaccountname host$
# and the domain's sid
if domain_sid is not None:
if computer_object.matches_samaccountname(member.host_name) and \
computer_object.ObjectIdentifier.startswith(domain_sid):

computer_found = True

# if we've got the computer, then check the sid before submitting
if not computer_found:
continue

color = ColorScheme.user if member.member_sid_type == "User" else ColorScheme.group

computer_object.add_local_group_member(member.member_sid, member.member_sid_type, member.group)
logging.debug(f"Resolved {color}{member.member}[/] as member of {ColorScheme.group}{member.group}[/] on {ColorScheme.computer}{computer_object.Properties['name']}[/]", extra=OBJ_EXTRA_FMT)
3 changes: 2 additions & 1 deletion bofhound/ad/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@
from .bloodhound_schema import BloodHoundSchema
from .bloodhound_ou import BloodHoundOU
from .bloodhound_gpo import BloodHoundGPO
from .bloodhound_domaintrust import BloodHoundDomainTrust
from .bloodhound_domaintrust import BloodHoundDomainTrust
from .bloodhound_crossref import BloodHoundCrossRef
Loading

0 comments on commit ee84bdf

Please sign in to comment.