1
1
import json
2
2
from datetime import datetime
3
+ from functools import cmp_to_key
3
4
from typing import Optional
4
5
6
+ from django import forms
5
7
from django .contrib .auth .decorators import permission_required
6
8
from django .contrib .auth .mixins import PermissionRequiredMixin
7
9
from django .http import HttpRequest , JsonResponse
8
10
from django .shortcuts import redirect
9
11
from django .urls import reverse_lazy
10
12
from django .views import View
11
13
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
13
16
14
17
from event_tracker .models import BloodhoundServer , HashCatMode , Credential
15
18
from event_tracker .signals import get_driver_for
16
19
17
20
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
+
18
64
def get_bh_users (tx , q ):
19
65
users = set ()
20
66
@@ -43,8 +89,8 @@ class BloodhoundServerListView(PermissionRequiredMixin, ListView):
43
89
ordering = ['neo4j_connection_url' ]
44
90
45
91
46
- def _get_kerberoastables (tx , system : Optional [str ]):
47
- if system :
92
+ def _get_kerberoastables (tx , domain : Optional [str ]):
93
+ if domain :
48
94
return tx .run ("""
49
95
match (n:User) where
50
96
n.domain = $system and
@@ -53,7 +99,7 @@ def _get_kerberoastables(tx, system: Optional[str]):
53
99
OPTIONAL MATCH shortestPath((n:User)-[:MemberOf]->(g:Group)) WHERE g.highvalue=true
54
100
return
55
101
toLower(n.name), toLower(g.name)
56
- order by n.name""" , system = system .upper ()).values ()
102
+ order by n.name""" , system = domain .upper ()).values ()
57
103
else :
58
104
return tx .run ("""
59
105
match (n:User) where
@@ -65,8 +111,8 @@ def _get_kerberoastables(tx, system: Optional[str]):
65
111
order by n.domain, n.name""" ).values ()
66
112
67
113
68
- def _get_asreproastables (tx , system : Optional [str ]):
69
- if system :
114
+ def _get_asreproastables (tx , domain : Optional [str ]):
115
+ if domain :
70
116
return tx .run ("""
71
117
match (n:User) where
72
118
n.domain = $system and
@@ -75,7 +121,7 @@ def _get_asreproastables(tx, system: Optional[str]):
75
121
OPTIONAL MATCH shortestPath((n:User)-[:MemberOf]->(g:Group)) WHERE g.highvalue=true
76
122
return
77
123
toLower(n.name), toLower(g.name)
78
- order by n.name""" , system = system .upper ()).values ()
124
+ order by n.name""" , system = domain .upper ()).values ()
79
125
else :
80
126
return tx .run ("""
81
127
match (n:User) where
@@ -87,19 +133,19 @@ def _get_asreproastables(tx, system: Optional[str]):
87
133
order by n.domain, n.name""" ).values ()
88
134
89
135
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 :
92
138
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 ()
94
140
else :
95
141
return tx .run (
96
142
"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" ,
97
143
most_recent_machine_login = most_recent_machine_login ).values ()
98
144
99
145
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 ]
103
149
else :
104
150
return tx .run ("match (n:Computer) return max(n.lastlogontimestamp)" ).single ()[0 ]
105
151
@@ -218,14 +264,28 @@ def toggle_bloodhound_node_highvalue(request, dn):
218
264
return redirect (reverse_lazy ('event_tracker:bloodhound-node' , kwargs = {"dn" : dn }))
219
265
220
266
221
- class BloodhoundServerStatsView (PermissionRequiredMixin , TemplateView ):
267
+ class BloodhoundServerStatsView (PermissionRequiredMixin , FormView ):
222
268
permission_required = 'event_tracker.view_bloodhoundserver'
223
269
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 ())
224
283
225
284
def get_context_data (self , ** kwargs ):
226
285
context = super ().get_context_data (** kwargs )
227
286
228
- system = None # Todo make this configurable
287
+ statsfilter = self .request .session .get ('bloodhoundstatsfilter' , {})
288
+ domain = statsfilter .get ("domain" , None ) or None
229
289
230
290
kerberosoatable_hashtypes = [HashCatMode .Kerberos_5_TGSREP_RC4 ,
231
291
HashCatMode .Kerberos_5_TGSREP_AES128 ,
@@ -250,29 +310,30 @@ def get_context_data(self, **kwargs):
250
310
with driver .session () as session :
251
311
try :
252
312
# 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 )
254
314
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 ,
256
316
int (most_recent_machine_login ))
257
317
for result in results :
258
318
if not result [0 ]:
259
319
continue
260
320
if result [0 ] not in os_distribution :
261
321
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' )
264
325
os_distribution [result [0 ]] += result [1 ]
265
326
# Kerberoastables
266
- results = session .execute_read (_get_kerberoastables , system )
327
+ results = session .execute_read (_get_kerberoastables , domain )
267
328
for result in results :
268
329
user_parts = result [0 ].split ('@' )
269
330
username = user_parts [0 ].lower ()
270
331
domain = user_parts [1 ].lower ()
271
332
kerberoastable_domains .add (domain )
272
333
273
334
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 )
276
337
277
338
credential_obj = credential_obj_query .order_by ("hash_type" ).first ()
278
339
kerberoastable_users [username ] = {"credential" : credential_obj ,
@@ -285,7 +346,7 @@ def get_context_data(self, **kwargs):
285
346
kerberoastable_cracked_count += 1
286
347
287
348
# ASREP roastable users
288
- results = session .execute_read (_get_asreproastables , system )
349
+ results = session .execute_read (_get_asreproastables , domain )
289
350
for result in results :
290
351
user_parts = result [0 ].split ('@' )
291
352
username = user_parts [0 ].lower ()
@@ -294,8 +355,8 @@ def get_context_data(self, **kwargs):
294
355
295
356
credential_obj_query = Credential .objects .filter (account__iexact = username ,
296
357
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 )
299
360
300
361
credential_obj = credential_obj_query .order_by ("hash_type" ).first ()
301
362
asreproastable_users [username ] = {"credential" : credential_obj ,
0 commit comments