Skip to content

Commit

Permalink
Add parser for Brute Ratel's LDAP Sentinel (#6)
Browse files Browse the repository at this point in the history
* initial sentinel parser and test cases

* add MemberOfDNs obj attr for brc4 tracking

* update brc4 parser to handle formatting diffs

* determine group mems from both sides of relation

* logic for parsing brc4 sentinel logs

* fix comments

* update readme

* prep changelog/version

* handle nested groups for sentinel

* make consistent with _is_member_of method

* ignore errors on utf-8 decode

* gplink quirk

* fix ISO8601 timestamps

* new brc4 test data from v1.5.1

* typo

* update changelog
  • Loading branch information
Tw1sm authored Mar 28, 2023
1 parent ad6a3e5 commit 4a97823
Show file tree
Hide file tree
Showing 15 changed files with 17,573 additions and 13 deletions.
Binary file modified .assets/usage.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
# Changelog
## [0.2.0] - 03/28/2023
### Added
- New parser to support parsing LDAP Sentinel data from BRc4 logs

### Changed
- Modified logic for how group memberships are determined
- Prior method was iterate through DNs in groups' `member` attribute and adding objects with matching DNs
- Since BRc4 does not store DNs in the `member` attibute, added iteration over objects' `memberOf` attribute and add to groups with matching DN (i.e. membership is now calculated from both sides of relationship)

## [v0.1.2] - 2/10/2023
### Changed
- Updated ACL parsing function to current version BloodHound.py
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

# BOFHound

BOFHound is an offline BloodHound ingestor and LDAP result parser compatible with TrustedSec's [ldapsearch BOF](https://github.com/trustedsec/CS-Situational-Awareness-BOF) and the Python adaptation, [pyldapsearch](https://github.com/fortalice/pyldapsearch).
BOFHound is an offline BloodHound ingestor and LDAP result parser compatible with TrustedSec's [ldapsearch BOF](https://github.com/trustedsec/CS-Situational-Awareness-BOF), the Python adaptation, [pyldapsearch](https://github.com/fortalice/pyldapsearch) and Brute Ratel's [LDAP Sentinel](https://bruteratel.com/tabs/commander/badgers/#ldapsentinel).

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.

Expand All @@ -37,6 +37,11 @@ Parse pyldapsearch logs and only include all properties (vs only common properti
bofhound -i ~/.pyldapsearch/logs/ --all-properties
```

Parse LDAP Sentinel data from BRc4 logs (will change default input path to `/opt/bruteratel/logs`)
```
bofhound --brute-ratel
```

# ldapsearch

## Required Data
Expand Down
30 changes: 24 additions & 6 deletions bofhound/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,26 @@
import typer
import glob
import os
from bofhound.parsers import LdapSearchBofParser
from bofhound.parsers import LdapSearchBofParser, Brc4LdapSentinelParser
from bofhound.writer import BloodHoundWriter
from bofhound.ad import ADDS
from bofhound import console

app = typer.Typer(add_completion=False)
app = typer.Typer(
add_completion=False,
rich_markup_mode="rich"
)

@app.command()
def main(
input_files: str = typer.Option("/opt/cobaltstrike/logs", "--input", "-i", help="Directory or file containing logs of ldapsearch results"),
input_files: str = typer.Option("/opt/cobaltstrike/logs", "--input", "-i", help="Directory or file containing logs of ldapsearch results. Will default to [green]/opt/bruteratel/logs[/] if --brute-ratel is specified"),
output_folder: str = typer.Option(".", "--output", "-o", help="Location to export bloodhound files"),
all_properties: bool = typer.Option(False, "--all-properties", "-a", help="Write all properties to BloodHound files (instead of only common properties)"),
brute_ratel: bool = typer.Option(False, "--brute-ratel", help="Parse logs from Brute Ratel's LDAP Sentinel"),
debug: bool = typer.Option(False, "--debug", help="Enable debug output"),
zip_files: bool = typer.Option(False, "--zip", "-z", help="Compress the JSON output files into a zip archive")):
"""
Generate BloodHound compatible JSON from logs written by ldapsearch BOF and pyldapsearch
Generate BloodHound compatible JSON from logs written by ldapsearch BOF, pyldapsearch and Brute Ratel's LDAP Sentinel
"""

if debug:
Expand All @@ -28,12 +32,21 @@ def main(

banner()

# if BRc4 and input_files is the default, set it to the default BRc4 logs directory
if brute_ratel and input_files == "/opt/cobaltstrike/logs":
input_files = "/opt/bruteratel/logs"

# default to Cobalt logfile naming format
logfile_name_format = "beacon*.log"
if brute_ratel:
logfile_name_format = "b-*.log"

if os.path.isfile(input_files):
cs_logs = [input_files]
logging.debug(f"Log file explicitly provided {input_files}")
elif os.path.isdir(input_files):
# recurisively get a list of all .log files in the input directory, sorted by last modified time
cs_logs = glob.glob(f"{input_files}/**/beacon*.log", recursive=True)
cs_logs = glob.glob(f"{input_files}/**/{logfile_name_format}", recursive=True)
if len(cs_logs) == 0:
# check for ldapsearch python logs
cs_logs = glob.glob(f"{input_files}/pyldapsearch*.log", recursive=True)
Expand All @@ -49,11 +62,16 @@ def main(
logging.error(f"Could not find {input_files} on disk")
sys.exit(-1)

parser = LdapSearchBofParser
if brute_ratel:
logging.debug('Using Brute Ratel parser')
parser = Brc4LdapSentinelParser

parsed_objects = []
with console.status(f"", spinner="aesthetic") as status:
for log in cs_logs:
status.update(f" [bold] Parsing {log}")
new_objects = LdapSearchBofParser.parse_file(log)
new_objects = parser.parse_file(log)
logging.debug(f"Parsed {log}")
logging.debug(f"Found {len(new_objects)} objects in {log}")
parsed_objects.extend(new_objects)
Expand Down
17 changes: 14 additions & 3 deletions bofhound/ad/adds.py
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,12 @@ def _is_member_of(self, member, group):
if ADDS.AT_DISTINGUISHEDNAME in member.Properties.keys():
if member.Properties["distinguishedname"] in group.MemberDNs:
return True

# BRc4 does not use DN in groups' member attribute, so we have
# to check membership from the other side of the relationship
if ADDS.AT_DISTINGUISHEDNAME in group.Properties.keys():
if group.Properties["distinguishedname"] in member.MemberOfDNs:
return True

if member.PrimaryGroupSid == group.ObjectIdentifier:
return True
Expand All @@ -667,11 +673,16 @@ def _is_member_of(self, member, group):


def _is_nested_group(self, subgroup, group):
try:
if ADDS.AT_DISTINGUISHEDNAME in subgroup.Properties.keys():
if subgroup.Properties["distinguishedname"] in group.MemberDNs:
return True
except:
pass

if ADDS.AT_DISTINGUISHEDNAME in group.Properties.keys():
# BRc4 does not use DN in groups' member attribute, so we have
# to check membership from the other side of the relationship
if group.Properties["distinguishedname"] in subgroup.MemberOfDNs:
return True

return False


Expand Down
6 changes: 6 additions & 0 deletions bofhound/ad/models/bloodhound_computer.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def __init__(self, object):
self.PrimaryGroupSid = self.get_primary_membership(object) # Returns none if non-existent
self.sessions = None #['not currently supported by bofhound']
self.AllowedToDelegate = []
self.MemberOfDNs = []

if self.ObjectIdentifier:
self.Properties['domainsid'] = self.get_domain_sid()
Expand Down Expand Up @@ -104,6 +105,11 @@ def __init__(self, object):
if 'ntsecuritydescriptor' in object.keys():
self.RawAces = object['ntsecuritydescriptor']

if 'memberof' in object.keys():
self.MemberOfDNs = [f'CN={dn.upper()}' for dn in object.get('memberof').split(', CN=')]
if len(self.MemberOfDNs) > 0:
self.MemberOfDNs[0] = self.MemberOfDNs[0][3:]


def to_json(self, only_common_properties=True):
data = super().to_json(only_common_properties)
Expand Down
6 changes: 5 additions & 1 deletion bofhound/ad/models/bloodhound_domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,12 @@ def __init__(self, object):
self.RawAces = object['ntsecuritydescriptor']

if 'gplink' in object.keys():
# this is gross - not sure why gplink is coming in without a colon
# (even from logs in test folder) but will hunt down later if it's a problem
links = object.get('gplink').replace('LDAP//', 'LDAP://')

# [['DN1', 'GPLinkOptions1'], ['DN2', 'GPLinkOptions2'], ...]
self.GPLinks = [link.upper()[:-1].split(';') for link in object.get('gplink').split('[LDAP//')][1:]
self.GPLinks = [link.upper()[:-1].split(';') for link in links.split('[LDAP://')][1:]

self.Properties["highvalue"] = True

Expand Down
6 changes: 6 additions & 0 deletions bofhound/ad/models/bloodhound_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def __init__(self, object):
self.IsDeleted = False
self.IsACLProtected = False
self.MemberDNs = []
self.MemberOfDNs = []
self.IsACLProtected = False

if 'distinguishedname' in object.keys() and 'samaccountname' in object.keys():
Expand Down Expand Up @@ -57,6 +58,11 @@ def __init__(self, object):
if 'ntsecuritydescriptor' in object.keys():
self.RawAces = object['ntsecuritydescriptor']

if 'memberof' in object.keys():
self.MemberOfDNs = [f'CN={dn.upper()}' for dn in object.get('memberof').split(', CN=')]
if len(self.MemberOfDNs) > 0:
self.MemberOfDNs[0] = self.MemberOfDNs[0][3:]


def add_group_member(self, object, object_type):
member = {
Expand Down
6 changes: 6 additions & 0 deletions bofhound/ad/models/bloodhound_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def __init__(self, object=None):
self.SPNTargets = []
self.HasSIDHistory = []
self.IsACLProtected = False
self.MemberOfDNs = []

if isinstance(object, dict):
self.PrimaryGroupSid = self.get_primary_membership(object) # Returns none if not exist
Expand Down Expand Up @@ -115,6 +116,11 @@ def __init__(self, object=None):
if 'ntsecuritydescriptor' in object.keys():
self.RawAces = object['ntsecuritydescriptor']

if 'memberof' in object.keys():
self.MemberOfDNs = [f'CN={dn.upper()}' for dn in object.get('memberof').split(', CN=')]
if len(self.MemberOfDNs) > 0:
self.MemberOfDNs[0] = self.MemberOfDNs[0][3:]


def to_json(self, only_common_properties=True):
user = super().to_json(only_common_properties)
Expand Down
1 change: 1 addition & 0 deletions bofhound/parsers/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from .ldap_search_bof import LdapSearchBofParser
from.brc4_ldap_sentinel import Brc4LdapSentinelParser
118 changes: 118 additions & 0 deletions bofhound/parsers/brc4_ldap_sentinel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import re
import codecs
import logging
from datetime import datetime as dt

# If field is empty, DO NOT WRITE IT TO FILE!

class Brc4LdapSentinelParser():
# BRC4 LDAP Sentinel currently only queries attributes=["*"] and objectClass
# is always the top result. May need to be updated in the future.
START_BOUNDARY = '[+] objectclass :'
END_BOUNDARY = '+-------------------------------------------------------------------+'

FORMATTED_TS_ATTRS = ['lastlogontimestamp', 'lastlogon', 'lastlogoff', 'pwdlastset', 'accountexpires']
ISO_8601_TS_ATTRS = ['dscorepropagationdata', 'whenchanged', 'whencreated']
BRACKETED_ATTRS = ['objectguid']
SEMICOLON_DELIMITED_ATTRS = ['serviceprincipalname', 'memberof', 'member', 'objectclass']

def __init__(self):
pass #self.objects = []

@staticmethod
def parse_file(file):

with codecs.open(file, 'r', 'utf-8', errors='ignore') as f:
return Brc4LdapSentinelParser.parse_data(f.read())

@staticmethod
def parse_data(contents):
parsed_objects = []
current_object = None
in_result_region = False

in_result_region = False

lines = contents.splitlines()
for line in lines:

if len(line) == 0:
continue

is_start_boundary_line = Brc4LdapSentinelParser._is_start_boundary_line(line)
is_end_boundary_line = Brc4LdapSentinelParser._is_end_boundary_line(line)

if not in_result_region and not is_start_boundary_line:
continue

if is_start_boundary_line:
if not in_result_region:
in_result_region = True

current_object = {}

elif is_end_boundary_line:
parsed_objects.append(current_object)
in_result_region = False
current_object = None
continue

data = line.split(': ')

try:
data = line.split(':', 1)
attr = data[0].replace('[+]', '').strip().lower()
value = data[1].strip()

# BRc4 formats some timestamps for us that we need to revert to raw values
if attr in Brc4LdapSentinelParser.FORMATTED_TS_ATTRS:
if value.lower() in ['never expires', 'value not set']:
continue
timestamp_obj = dt.strptime(value, '%m/%d/%Y %I:%M:%S %p')
value = int((timestamp_obj - dt(1601, 1, 1)).total_seconds() * 10000000)

if attr in Brc4LdapSentinelParser.ISO_8601_TS_ATTRS:
formatted_ts = []
for ts in value.split(';'):
timestamp_obj = dt.strptime(ts.strip(), "%m/%d/%Y %I:%M:%S %p")
timestamp_str = timestamp_obj.strftime("%Y%m%d%H%M%S.0Z")
formatted_ts.append(timestamp_str)
value = ', '.join(formatted_ts)

# BRc4 formats some attributes with surroudning {} we need to remove
if attr in Brc4LdapSentinelParser.BRACKETED_ATTRS:
value = value[1:-1]

# BRc4 delimits some list-esque attributes with semicolons
# when our BH models expect commas
if attr in Brc4LdapSentinelParser.SEMICOLON_DELIMITED_ATTRS:
value = value.replace('; ', ', ')

# BRc4 puts the trustDirection attribute within securityidentifier
if attr == 'securityidentifier' and 'trustdirection' in value.lower():
trust_direction = value.lower().split('trustdirection ')[1]
current_object['trustdirection'] = trust_direction
value = value.split('trustdirection: ')[0]
continue

current_object[attr] = value

except Exception as e:
logging.debug(f'Error - {str(e)}')

return parsed_objects


@staticmethod
def _is_start_boundary_line(line):
# BRc4 seems to always have objectClass camelcased, but we'll use lower() just in case
if line.lower().startswith(Brc4LdapSentinelParser.START_BOUNDARY):
return True
return False


@staticmethod
def _is_end_boundary_line(line):
if line == Brc4LdapSentinelParser.END_BOUNDARY:
return True
return False
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "bofhound"
version = "0.1.2"
version = "0.2.0"
description = "Parse output from common sources and transform it into BloodHound-ingestible data"
authors = [
"Adam Brown",
Expand Down
Loading

0 comments on commit 4a97823

Please sign in to comment.