Skip to content

Commit 19c7f95

Browse files
committed
Add domain dropdown on BloodHound stats page.
Closes #17
1 parent bd22eaa commit 19c7f95

File tree

2 files changed

+95
-25
lines changed

2 files changed

+95
-25
lines changed

event_tracker/templates/event_tracker/bloodhoundserver_stats.html

+9
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,15 @@
3737
</script>
3838
{% endblock %}
3939

40+
{% block filter %}
41+
{{ form.media }}
42+
<form class="d-flex me-2" method="post">
43+
<label for="system" class="d-flex align-items-center text-white-50">Domain:&nbsp;&nbsp;</label>
44+
{% csrf_token %}
45+
{{ form.domain }}
46+
</form>
47+
{% endblock filter %}
48+
4049
{% block body %}
4150
{% block bootstrap5_content %}
4251
<div class="container-fluid">

event_tracker/views_bloodhound.py

+86-25
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,66 @@
11
import json
22
from datetime import datetime
3+
from functools import cmp_to_key
34
from typing import Optional
45

6+
from django import forms
57
from django.contrib.auth.decorators import permission_required
68
from django.contrib.auth.mixins import PermissionRequiredMixin
79
from django.http import HttpRequest, JsonResponse
810
from django.shortcuts import redirect
911
from django.urls import reverse_lazy
1012
from django.views import View
1113
from django.views.decorators.clickjacking import xframe_options_exempt
12-
from django.views.generic import ListView, TemplateView, CreateView, UpdateView, DeleteView
14+
from django.views.generic import ListView, TemplateView, CreateView, UpdateView, DeleteView, FormView
15+
from neo4j.exceptions import ServiceUnavailable
1316

1417
from event_tracker.models import BloodhoundServer, HashCatMode, Credential
1518
from event_tracker.signals import get_driver_for
1619

1720

21+
def domain_name_comparer(domain_a, domain_b) -> bool:
22+
if domain_a.count(".") != domain_b.count("."):
23+
return domain_a.count(".") - domain_b.count(".")
24+
elif domain_a < domain_b:
25+
return -1
26+
elif domain_a > domain_b:
27+
return 1
28+
else:
29+
return 0
30+
31+
32+
class BloodhoundStatsFilter(forms.Form):
33+
class Media:
34+
js = ["scripts/ss-forms.js"]
35+
36+
def __init__(self, **kwargs):
37+
super(BloodhoundStatsFilter, self).__init__(**kwargs)
38+
39+
domains = set()
40+
41+
for bloodhound_server in BloodhoundServer.objects.filter(active=True).all():
42+
if driver := get_driver_for(bloodhound_server):
43+
try:
44+
with driver.session() as session:
45+
domains.update(session.execute_read(_get_distinct_domains))
46+
except ServiceUnavailable:
47+
print("Timeout talking to neo4j for domain filter")
48+
49+
choices = [('', f"All domains")]
50+
choices += sorted(list(zip(domains, domains)), key=cmp_to_key(domain_name_comparer))
51+
self.fields['domain'] = forms.ChoiceField(choices=choices, required=False,
52+
widget=forms.Select(attrs={'class': 'form-select form-select-sm submit-on-change'}))
53+
54+
55+
def _get_distinct_domains(tx):
56+
domains = set()
57+
58+
result = tx.run("MATCH (n:Base) return collect(distinct toLower(n.domain))")
59+
for domain in result:
60+
domains.update(domain[0])
61+
62+
return domains
63+
1864
def get_bh_users(tx, q):
1965
users = set()
2066

@@ -43,8 +89,8 @@ class BloodhoundServerListView(PermissionRequiredMixin, ListView):
4389
ordering = ['neo4j_connection_url']
4490

4591

46-
def _get_kerberoastables(tx, system: Optional[str]):
47-
if system:
92+
def _get_kerberoastables(tx, domain: Optional[str]):
93+
if domain:
4894
return tx.run("""
4995
match (n:User) where
5096
n.domain = $system and
@@ -53,7 +99,7 @@ def _get_kerberoastables(tx, system: Optional[str]):
5399
OPTIONAL MATCH shortestPath((n:User)-[:MemberOf]->(g:Group)) WHERE g.highvalue=true
54100
return
55101
toLower(n.name), toLower(g.name)
56-
order by n.name""", system=system.upper()).values()
102+
order by n.name""", system=domain.upper()).values()
57103
else:
58104
return tx.run("""
59105
match (n:User) where
@@ -65,8 +111,8 @@ def _get_kerberoastables(tx, system: Optional[str]):
65111
order by n.domain, n.name""").values()
66112

67113

68-
def _get_asreproastables(tx, system: Optional[str]):
69-
if system:
114+
def _get_asreproastables(tx, domain: Optional[str]):
115+
if domain:
70116
return tx.run("""
71117
match (n:User) where
72118
n.domain = $system and
@@ -75,7 +121,7 @@ def _get_asreproastables(tx, system: Optional[str]):
75121
OPTIONAL MATCH shortestPath((n:User)-[:MemberOf]->(g:Group)) WHERE g.highvalue=true
76122
return
77123
toLower(n.name), toLower(g.name)
78-
order by n.name""", system=system.upper()).values()
124+
order by n.name""", system=domain.upper()).values()
79125
else:
80126
return tx.run("""
81127
match (n:User) where
@@ -87,19 +133,19 @@ def _get_asreproastables(tx, system: Optional[str]):
87133
order by n.domain, n.name""").values()
88134

89135

90-
def _get_recent_os_distribution(tx, system: Optional[str], most_recent_machine_login):
91-
if system:
136+
def _get_recent_os_distribution(tx, domain: Optional[str], most_recent_machine_login):
137+
if domain:
92138
return tx.run("match (n:Computer) where n.domain = $system and n.lastlogontimestamp > $most_recent_machine_login - 2628000 return n.operatingsystem as os, count(n.operatingsystem) as freq order by os",
93-
system=system.upper(), most_recent_machine_login=most_recent_machine_login).values()
139+
system=domain.upper(), most_recent_machine_login=most_recent_machine_login).values()
94140
else:
95141
return tx.run(
96142
"match (n:Computer) where n.lastlogontimestamp > $most_recent_machine_login - 2628000 return n.operatingsystem as os, count(n.operatingsystem) as freq order by os desc",
97143
most_recent_machine_login=most_recent_machine_login).values()
98144

99145

100-
def _get_most_recent_machine_login(tx, system: Optional[str]):
101-
if system:
102-
return tx.run("match (n:Computer) where n.domain = $system return max(n.lastlogontimestamp)", system=system.upper()).single()[0]
146+
def _get_most_recent_machine_login(tx, domain: Optional[str]):
147+
if domain:
148+
return tx.run("match (n:Computer) where n.domain = $system return max(n.lastlogontimestamp)", system=domain.upper()).single()[0]
103149
else:
104150
return tx.run("match (n:Computer) return max(n.lastlogontimestamp)").single()[0]
105151

@@ -218,14 +264,28 @@ def toggle_bloodhound_node_highvalue(request, dn):
218264
return redirect(reverse_lazy('event_tracker:bloodhound-node', kwargs={"dn": dn}))
219265

220266

221-
class BloodhoundServerStatsView(PermissionRequiredMixin, TemplateView):
267+
class BloodhoundServerStatsView(PermissionRequiredMixin, FormView):
222268
permission_required = 'event_tracker.view_bloodhoundserver'
223269
template_name = 'event_tracker/bloodhoundserver_stats.html'
270+
form_class = BloodhoundStatsFilter
271+
272+
def get_initial(self):
273+
"""
274+
Merge the session stored filter into the form's initial state
275+
"""
276+
initial = super().get_initial()
277+
initial.update(self.request.session.get("bloodhoundstatsfilter", default={}))
278+
return initial
279+
280+
def form_valid(self, form):
281+
self.request.session['bloodhoundstatsfilter'] = form.cleaned_data
282+
return self.render_to_response(self.get_context_data())
224283

225284
def get_context_data(self, **kwargs):
226285
context = super().get_context_data(**kwargs)
227286

228-
system = None # Todo make this configurable
287+
statsfilter = self.request.session.get('bloodhoundstatsfilter', {})
288+
domain = statsfilter.get("domain", None) or None
229289

230290
kerberosoatable_hashtypes = [HashCatMode.Kerberos_5_TGSREP_RC4,
231291
HashCatMode.Kerberos_5_TGSREP_AES128,
@@ -250,29 +310,30 @@ def get_context_data(self, **kwargs):
250310
with driver.session() as session:
251311
try:
252312
# Machine OS
253-
most_recent_machine_login = session.execute_read(_get_most_recent_machine_login, system)
313+
most_recent_machine_login = session.execute_read(_get_most_recent_machine_login, domain)
254314
if most_recent_machine_login:
255-
results = session.execute_read(_get_recent_os_distribution, system,
315+
results = session.execute_read(_get_recent_os_distribution, domain,
256316
int(most_recent_machine_login))
257317
for result in results:
258318
if not result[0]:
259319
continue
260320
if result[0] not in os_distribution:
261321
os_distribution[result[0]] = 0
262-
# TODO incorperate the domain into the query when we support filtering stats by domain
263-
os_distribution_query[result[0]] = f'MATCH (n:Computer) WHERE n.lastlogontimestamp > {int(most_recent_machine_login) - 2628000} AND n.operatingsystem = {json.dumps(result[0])} RETURN n'
322+
os_distribution_query[result[0]] = (f'MATCH (n:Computer) WHERE n.lastlogontimestamp > {int(most_recent_machine_login) - 2628000} AND n.operatingsystem = {json.dumps(result[0])}' +
323+
(f' AND n.domain = {json.dumps(domain.upper())}' if domain else '') +
324+
f' RETURN n')
264325
os_distribution[result[0]] += result[1]
265326
# Kerberoastables
266-
results = session.execute_read(_get_kerberoastables, system)
327+
results = session.execute_read(_get_kerberoastables, domain)
267328
for result in results:
268329
user_parts = result[0].split('@')
269330
username = user_parts[0].lower()
270331
domain = user_parts[1].lower()
271332
kerberoastable_domains.add(domain)
272333

273334
credential_obj_query = Credential.objects.filter(account__iexact=username, hash_type__in=kerberosoatable_hashtypes)
274-
if system:
275-
credential_obj_query = credential_obj_query.filter(system=system)
335+
if domain:
336+
credential_obj_query = credential_obj_query.filter(system=domain)
276337

277338
credential_obj = credential_obj_query.order_by("hash_type").first()
278339
kerberoastable_users[username] = {"credential": credential_obj,
@@ -285,7 +346,7 @@ def get_context_data(self, **kwargs):
285346
kerberoastable_cracked_count += 1
286347

287348
# ASREP roastable users
288-
results = session.execute_read(_get_asreproastables, system)
349+
results = session.execute_read(_get_asreproastables, domain)
289350
for result in results:
290351
user_parts = result[0].split('@')
291352
username = user_parts[0].lower()
@@ -294,8 +355,8 @@ def get_context_data(self, **kwargs):
294355

295356
credential_obj_query = Credential.objects.filter(account__iexact=username,
296357
hash_type__in=asreproastable_hashtypes)
297-
if system:
298-
credential_obj_query = credential_obj_query.filter(system=system)
358+
if domain:
359+
credential_obj_query = credential_obj_query.filter(system=domain)
299360

300361
credential_obj = credential_obj_query.order_by("hash_type").first()
301362
asreproastable_users[username] = {"credential": credential_obj,

0 commit comments

Comments
 (0)