Skip to content

Commit

Permalink
jank updates to support OutflankC2 json
Browse files Browse the repository at this point in the history
  • Loading branch information
Tw1sm committed Oct 18, 2024
1 parent d811dd6 commit 42392e5
Show file tree
Hide file tree
Showing 5 changed files with 172 additions and 11 deletions.
16 changes: 12 additions & 4 deletions bofhound/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import typer
import glob
import os
from bofhound.parsers import LdapSearchBofParser, Brc4LdapSentinelParser, GenericParser
from bofhound.parsers import LdapSearchBofParser, Brc4LdapSentinelParser, GenericParser, OutflankC2JsonParser
from bofhound.writer import BloodHoundWriter
from bofhound.ad import ADDS
from bofhound.local import LocalBroker
Expand All @@ -16,10 +16,11 @@

@app.command()
def main(
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"),
input_files: str = typer.Option("/opt/cobaltstrike/logs", "--input", "-i", help="Directory or file containing logs of ldapsearch results"),
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"),
brute_ratel: bool = typer.Option(False, "--brute-ratel", help="Parse logs from Brute Ratel's LDAP Sentinel. Defaults to [green]/opt/bruteratel/logs[/]"),
outflankc2: bool = typer.Option(False, "--outflankc2", help="Parse JSON logs from Outflank C2. Defaults to [green]/opt/stage1/shared/logs/api/implant_logs/json[/]"),
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")):
"""
Expand All @@ -36,11 +37,15 @@ def main(
# 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"
elif outflankc2 and input_files == "/opt/cobaltstrike/logs":
input_files = "/opt/stage1/shared/logs/api/implant_logs/json"

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

if os.path.isfile(input_files):
cs_logs = [input_files]
Expand All @@ -67,14 +72,17 @@ def main(
if brute_ratel:
logging.debug('Using Brute Ratel parser')
parser = Brc4LdapSentinelParser
elif outflankc2:
logging.debug('Using OutflankC2 parser')
parser = OutflankC2JsonParser

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)
new_local_objects = GenericParser.parse_file(log, outflankc2)
logging.debug(f"Parsed {log}")
logging.debug(f"Found {len(new_objects)} objects in {log}")
parsed_ldap_objects.extend(new_objects)
Expand Down
1 change: 0 additions & 1 deletion bofhound/ad/models/bloodhound_ou.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from distutils.ccompiler import gen_preprocess_options
from bloodhound.ad.utils import ADUtils
from .bloodhound_object import BloodHoundObject
from bofhound.logger import OBJ_EXTRA_FMT, ColorScheme
Expand Down
1 change: 1 addition & 0 deletions bofhound/parsers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .ldap_search_bof import LdapSearchBofParser
from .brc4_ldap_sentinel import Brc4LdapSentinelParser
from .generic_parser import GenericParser
from .outflankc2 import OutflankC2JsonParser
35 changes: 29 additions & 6 deletions bofhound/parsers/generic_parser.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from .shared_parsers import __all_generic_parsers__
import codecs
import json


class GenericParser:

Expand All @@ -8,9 +10,33 @@ def __init__(self):


@staticmethod
def parse_file(file):
def parse_file(file, is_outflankc2):
with codecs.open(file, 'r', 'utf-8') as f:
return GenericParser.parse_data(f.read())
if is_outflankc2:
return GenericParser.parse_outflank_file(f.read())
else:
return GenericParser.parse_data(f.read())


@staticmethod
def parse_outflank_file(contents):
parsed_objects = []

for line in contents.splitlines():
event_json = json.loads(line.split('UTC ', 1)[1])

# we only care about task_resonse events
if event_json['event_type'] != 'task_response':
continue

# within task_response events, we only care about tasks with specific BOF names
if event_json['task']['name'].lower() not in ['netsession2', 'netloggedon2', 'regsession', 'netLocalGroupListMembers2']:
continue

parsed_objects.extend(GenericParser.parse_data(event_json['task']['response']))

return parsed_objects



@staticmethod
Expand Down Expand Up @@ -45,7 +71,4 @@ def parse_data(contents):
current_object = current_parser.parse_line(line, current_object)

return parsed_objects





130 changes: 130 additions & 0 deletions bofhound/parsers/outflankc2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import re
import base64
import codecs
import json

from io import BytesIO
from bloodhound.ad.utils import ADUtils
from bloodhound.enumeration.acls import parse_binary_acl, SecurityDescriptor
from bloodhound.enumeration.acls import ACL, ACCESS_ALLOWED_ACE, ACCESS_MASK, ACE, ACCESS_ALLOWED_OBJECT_ACE, build_relation, has_extended_right, EXTRIGHTS_GUID_MAPPING
import logging

from bofhound.ad.models import BloodHoundDomain, BloodHoundComputer, BloodHoundUser, BloodHoundGroup, BloodHoundSchema

#
# Parses ldapsearch BOF objects from Outflank C2 JSON logfiles
# Assumes that the BOF was registered as a command in OC2 named 'ldapserach'
#

class OutflankC2JsonParser():
RESULT_DELIMITER = "-"
RESULT_BOUNDARY_LENGTH = 20
_COMPLETE_BOUNDARY_LINE = -1

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

@staticmethod
def parse_file(file):

with codecs.open(file, 'r', 'utf-8') as f:
return OutflankC2JsonParser.parse_data(f.read())

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

in_result_region = False

lines = contents.splitlines()
for line in lines:
event_json = json.loads(line.split('UTC ', 1)[1])

# we only care about task_resonse events
if event_json['event_type'] != 'task_response':
continue

# within task_response events, we only care about tasks with the name 'ldapsearch'
if event_json['task']['name'].lower() != 'ldapsearch':
continue

# now we have a block of ldapsearch data we can parse through for objects
response_lines = event_json['task']['response'].splitlines()
for response_line in response_lines:

is_boundary_line = OutflankC2JsonParser._is_boundary_line(response_line)

if (not in_result_region and
not is_boundary_line):
continue

if (is_boundary_line
and is_boundary_line != OutflankC2JsonParser._COMPLETE_BOUNDARY_LINE):
while True:
try:
next_line = next(response_lines)[1]
remaining_length = OutflankC2JsonParser._is_boundary_line(next_line, is_boundary_line)

if remaining_length:
is_boundary_line = remaining_length
if is_boundary_line == OutflankC2JsonParser._COMPLETE_BOUNDARY_LINE:
break
except:
# probably ran past the end of the iterable
break

if (is_boundary_line):
if not in_result_region:
in_result_region = True
elif current_object is not None:
# self.store_object(current_object)
parsed_objects.append(current_object)
current_object = {}
continue
elif re.match("^(R|r)etr(e|i)(e|i)ved \\d+ results?", response_line):
#self.store_object(current_object)
parsed_objects.append(current_object)
in_result_region = False
current_object = None
continue

data = response_line.split(': ')

try:
# If we previously encountered a control message, we're probably still in the old property
if len(data) == 1:
if previous_attr is not None:
value = current_object[previous_attr] + response_line
else:
data = response_line.split(':')
attr = data[0].strip().lower()
value = ''.join(data[1:]).strip()
previous_attr = attr

current_object[attr] = value

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

return parsed_objects


# Returns one of the following integers:
# 0 - This is not a boundary line
# -1 - This is a complete boundary line
# n - The remaining characters needed to form a complete boundary line
@staticmethod
def _is_boundary_line(line, length=RESULT_BOUNDARY_LENGTH):
line = line.strip()
chars = set(line)

if len(chars) == 1 and chars.pop() == OutflankC2JsonParser.RESULT_DELIMITER:
if len(line) == length:
return -1
elif len(line) < length:
return OutflankC2JsonParser.RESULT_BOUNDARY_LENGTH - len(line)

return 0 # Falsey

0 comments on commit 42392e5

Please sign in to comment.