Skip to content

Commit 955c1ed

Browse files
committed
Remove docker-compose.test.yml and add access path functionality to BloodHound CLI
1 parent 72b023a commit 955c1ed

4 files changed

Lines changed: 311 additions & 49 deletions

File tree

docker-compose.test.yml

Lines changed: 0 additions & 37 deletions
This file was deleted.

docker-compose.yml

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# Copyright 2023 Specter Ops, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# SPDX-License-Identifier: Apache-2.0
16+
17+
services:
18+
app-db:
19+
image: docker.io/library/postgres:16
20+
environment:
21+
- PGUSER=${POSTGRES_USER:-bloodhound}
22+
- POSTGRES_USER=${POSTGRES_USER:-bloodhound}
23+
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-bloodhoundcommunityedition}
24+
- POSTGRES_DB=${POSTGRES_DB:-bloodhound}
25+
# Database ports are disabled by default. Please change your database password to something secure before uncommenting
26+
# ports:
27+
# - 127.0.0.1:${POSTGRES_PORT:-5432}:5432
28+
volumes:
29+
- postgres-data:/var/lib/postgresql/data
30+
healthcheck:
31+
test:
32+
[
33+
"CMD-SHELL",
34+
"pg_isready -U ${POSTGRES_USER:-bloodhound} -d ${POSTGRES_DB:-bloodhound} -h 127.0.0.1 -p 5432"
35+
]
36+
interval: 10s
37+
timeout: 5s
38+
retries: 5
39+
start_period: 30s
40+
41+
graph-db:
42+
image: docker.io/library/neo4j:4.4.42
43+
environment:
44+
- NEO4J_AUTH=${NEO4J_USER:-neo4j}/${NEO4J_SECRET:-bloodhoundcommunityedition}
45+
- NEO4J_dbms_allow__upgrade=${NEO4J_ALLOW_UPGRADE:-true}
46+
# Database ports are disabled by default. Please change your database password to something secure before uncommenting
47+
ports:
48+
- 127.0.0.1:${NEO4J_DB_PORT:-7687}:7687
49+
- 127.0.0.1:${NEO4J_WEB_PORT:-7474}:7474
50+
volumes:
51+
- ${NEO4J_DATA_MOUNT:-neo4j-data}:/data
52+
healthcheck:
53+
test:
54+
[
55+
"CMD-SHELL",
56+
"wget -O /dev/null -q http://localhost:7474 || exit 1"
57+
]
58+
interval: 10s
59+
timeout: 5s
60+
retries: 5
61+
start_period: 30s
62+
63+
bloodhound:
64+
image: docker.io/specterops/bloodhound:7.4.1
65+
environment:
66+
- bhe_disable_cypher_complexity_limit=${bhe_disable_cypher_complexity_limit:-false}
67+
- bhe_enable_cypher_mutations=${bhe_enable_cypher_mutations:-false}
68+
- bhe_graph_query_memory_limit=${bhe_graph_query_memory_limit:-2}
69+
- bhe_database_connection=user=${POSTGRES_USER:-bloodhound} password=${POSTGRES_PASSWORD:-bloodhoundcommunityedition} dbname=${POSTGRES_DB:-bloodhound} host=app-db
70+
- bhe_neo4j_connection=neo4j://${NEO4J_USER:-neo4j}:${NEO4J_SECRET:-bloodhoundcommunityedition}@graph-db:7687/
71+
- bhe_recreate_default_admin=${bhe_recreate_default_admin:-false}
72+
- bhe_graph_driver=${GRAPH_DRIVER:-neo4j}
73+
### Add additional environment variables you wish to use here.
74+
### For common configuration options that you might want to use environment variables for, see `.env.example`
75+
### example: bhe_database_connection=${bhe_database_connection}
76+
### The left side is the environment variable you're setting for bloodhound, the variable on the right in `${}`
77+
### is the variable available outside of Docker
78+
ports:
79+
### Default to localhost to prevent accidental publishing of the service to your outer networks
80+
### These can be modified by your .env file or by setting the environment variables in your Docker host OS
81+
- ${BLOODHOUND_HOST:-127.0.0.1}:${BLOODHOUND_PORT:-8080}:8080
82+
### Uncomment to use your own bloodhound.config.json to configure the application
83+
# volumes:
84+
# - ./bloodhound.config.json:/bloodhound.config.json:ro
85+
depends_on:
86+
app-db:
87+
condition: service_healthy
88+
graph-db:
89+
condition: service_healthy
90+
91+
volumes:
92+
neo4j-data:
93+
postgres-data:

src/bloodhound_cli/core/ce.py

Lines changed: 151 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -410,32 +410,171 @@ def get_critical_aces(self, source_domain: str, high_value: bool = False,
410410
return []
411411

412412
def get_access_paths(self, source: str, connection: str, target: str, domain: str) -> List[Dict]:
413-
"""Get access paths using CySQL query"""
413+
"""Get access paths using CySQL query - adapted from old_main.py"""
414414
try:
415-
cypher_query = f"""
416-
MATCH path = (s)-[*1..10]->(t)
417-
WHERE s.name = '{source}' AND t.name = '{target}'
418-
RETURN path
419-
"""
415+
# Determine relationship conditions
416+
if connection.lower() == "all":
417+
rel_condition = "AND type(r) IN ['AdminTo','CanRDP','CanPSRemote']"
418+
rel_pattern = "[r]->"
419+
else:
420+
rel_condition = ""
421+
rel_pattern = f"[r:{connection}]->"
422+
423+
# Case 1: source != "all" and target == "all" - find what source can access
424+
if source.lower() != "all" and target.lower() == "all":
425+
cypher_query = f"""
426+
MATCH p = (n)-{rel_pattern}(m)
427+
WHERE toLower(n.samaccountname) = toLower('{source}')
428+
AND toLower(n.domain) = toLower('{domain}')
429+
AND m.enabled = true
430+
{rel_condition}
431+
RETURN n.samaccountname AS source, m.samaccountname AS target, type(r) AS relation
432+
"""
433+
434+
# Case 2: source == "all" and target == "all" - find all access paths in domain
435+
elif source.lower() == "all" and target.lower() == "all":
436+
cypher_query = f"""
437+
MATCH p = (n)-{rel_pattern}(m)
438+
WHERE toLower(n.domain) = toLower('{domain}')
439+
AND n.enabled = true
440+
AND m.enabled = true
441+
{rel_condition}
442+
RETURN n.samaccountname AS source, m.samaccountname AS target, type(r) AS relation
443+
"""
444+
445+
# Case 3: source != "all" and target == "dcs" - find users with DC access
446+
elif source.lower() != "all" and target.lower() == "dcs":
447+
cypher_query = f"""
448+
MATCH p = (n)-{rel_pattern}(m)
449+
WHERE toLower(n.samaccountname) = toLower('{source}')
450+
AND toLower(n.domain) = toLower('{domain}')
451+
AND m.enabled = true
452+
AND (m.operatingsystem CONTAINS 'Windows Server' OR m.operatingsystem CONTAINS 'Domain Controller')
453+
{rel_condition}
454+
RETURN n.samaccountname AS source, m.samaccountname AS target, type(r) AS relation
455+
"""
456+
457+
# Case 4: source == "all" and target == "dcs" - find all users with DC access
458+
elif source.lower() == "all" and target.lower() == "dcs":
459+
cypher_query = f"""
460+
MATCH p = (n)-{rel_pattern}(m)
461+
WHERE toLower(n.domain) = toLower('{domain}')
462+
AND n.enabled = true
463+
AND m.enabled = true
464+
AND (m.operatingsystem CONTAINS 'Windows Server' OR m.operatingsystem CONTAINS 'Domain Controller')
465+
{rel_condition}
466+
RETURN n.samaccountname AS source, m.samaccountname AS target, type(r) AS relation
467+
"""
468+
469+
# Case 5: specific source to specific target
470+
else:
471+
cypher_query = f"""
472+
MATCH p = (n)-{rel_pattern}(m)
473+
WHERE toLower(n.samaccountname) = toLower('{source}')
474+
AND toLower(n.domain) = toLower('{domain}')
475+
AND toLower(m.samaccountname) = toLower('{target}')
476+
AND m.enabled = true
477+
{rel_condition}
478+
RETURN n.samaccountname AS source, m.samaccountname AS target, type(r) AS relation
479+
"""
420480

421481
result = self.execute_query(cypher_query)
422482
paths = []
423483

424484
if result and isinstance(result, list):
425-
for path_data in result:
426-
# Process path data - this might need adjustment based on actual CySQL response format
427-
if isinstance(path_data, dict):
485+
for record in result:
486+
source_name = record.get('source', '')
487+
target_name = record.get('target', '')
488+
relation = record.get('relation', '')
489+
490+
if source_name and target_name:
491+
# Extract just the username part (before @) if it's in UPN format
492+
if "@" in source_name:
493+
source_name = source_name.split("@")[0]
494+
if "@" in target_name:
495+
target_name = target_name.split("@")[0]
496+
428497
paths.append({
429-
"source": source,
430-
"target": target,
431-
"path": path_data
498+
"source": source_name,
499+
"target": target_name,
500+
"relation": relation,
501+
"path": f"{source_name} -> {target_name} ({relation})"
432502
})
433503

434504
return paths
435505

436506
except Exception:
437507
return []
438508

509+
def get_users_with_dc_access(self, domain: str) -> List[Dict]:
510+
"""Get users who have access to Domain Controllers"""
511+
try:
512+
# First try to find actual DCs
513+
cypher_query = f"""
514+
MATCH (u:User)-[r]->(dc:Computer)
515+
WHERE u.enabled = true AND toUpper(u.domain) = '{domain.upper()}'
516+
AND dc.enabled = true AND toUpper(dc.domain) = '{domain.upper()}'
517+
AND (dc.operatingsystem CONTAINS 'Windows Server' OR dc.operatingsystem CONTAINS 'Domain Controller')
518+
RETURN u.samaccountname AS user, dc.name AS dc, type(r) AS relation
519+
"""
520+
521+
result = self.execute_query(cypher_query)
522+
users_with_access = []
523+
524+
if result and isinstance(result, list):
525+
for record in result:
526+
user = record.get('user', '')
527+
dc = record.get('dc', '')
528+
relation = record.get('relation', '')
529+
530+
if user and dc:
531+
# Extract just the username part (before @) if it's in UPN format
532+
if "@" in user:
533+
user = user.split("@")[0]
534+
if "@" in dc:
535+
dc = dc.split("@")[0]
536+
537+
users_with_access.append({
538+
"source": user,
539+
"target": dc,
540+
"path": f"{user} -> {dc} ({relation})"
541+
})
542+
543+
# If no DCs found, try to find any user-computer relationships
544+
if not users_with_access:
545+
fallback_query = f"""
546+
MATCH (u:User)-[r]->(c:Computer)
547+
WHERE u.enabled = true AND toUpper(u.domain) = '{domain.upper()}'
548+
AND c.enabled = true AND toUpper(c.domain) = '{domain.upper()}'
549+
RETURN u.samaccountname AS user, c.name AS computer, type(r) AS relation
550+
"""
551+
552+
result = self.execute_query(fallback_query)
553+
554+
if result and isinstance(result, list):
555+
for record in result:
556+
user = record.get('user', '')
557+
computer = record.get('computer', '')
558+
relation = record.get('relation', '')
559+
560+
if user and computer:
561+
# Extract just the username part (before @) if it's in UPN format
562+
if "@" in user:
563+
user = user.split("@")[0]
564+
if "@" in computer:
565+
computer = computer.split("@")[0]
566+
567+
users_with_access.append({
568+
"source": user,
569+
"target": computer,
570+
"path": f"{user} -> {computer} ({relation})"
571+
})
572+
573+
return users_with_access
574+
575+
except Exception:
576+
return []
577+
439578
def get_critical_aces_by_domain(self, domain: str, blacklist: List[str],
440579
high_value: bool = False) -> List[Dict]:
441580
"""Get critical ACEs by domain using CySQL query"""

src/bloodhound_cli/main.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,65 @@ def cmd_upload(args):
377377
client.close()
378378

379379

380+
def cmd_access(args):
381+
"""Find access paths between objects"""
382+
if args.debug:
383+
print(f"Debug: Creating client for edition {args.edition}")
384+
print(f"Debug: Source = {args.source}")
385+
print(f"Debug: Target = {args.target}")
386+
print(f"Debug: Domain = {args.domain}")
387+
print(f"Debug: Relation = {args.relation}")
388+
389+
client = get_client(
390+
args.edition,
391+
uri=args.uri,
392+
user=args.user,
393+
password=args.password,
394+
base_url=args.base_url,
395+
username=args.username,
396+
ce_password=getattr(args, 'ce_password', 'Bloodhound123!'),
397+
debug=args.debug,
398+
verbose=args.verbose
399+
)
400+
401+
try:
402+
paths = client.get_access_paths(
403+
source=args.source,
404+
connection=args.relation or "all",
405+
target=args.target,
406+
domain=args.domain
407+
)
408+
409+
if args.verbose:
410+
print(f"\nAccess paths for source: {args.source}, connection: {args.relation or 'all'}, target: {args.target}, domain: {args.domain}")
411+
print("=" * 80)
412+
413+
if not paths:
414+
if args.verbose:
415+
print("No access paths found")
416+
else:
417+
print("No access paths found")
418+
return
419+
420+
# Format paths for output
421+
results = []
422+
for path in paths:
423+
if args.verbose:
424+
print(f"\nSource: {path['source']}")
425+
print(f"Target: {path['target']}")
426+
print(f"Relation: {path['relation']}")
427+
print(f"Path: {path['path']}")
428+
print("-" * 40)
429+
else:
430+
results.append(path['path'])
431+
432+
if not args.verbose:
433+
output_results(results, args.output, False, "access paths")
434+
435+
finally:
436+
client.close()
437+
438+
380439
def cmd_auth(args):
381440
"""Authenticate to BloodHound CE and save API token"""
382441
import getpass
@@ -507,6 +566,14 @@ def main():
507566
acl_parser.add_argument('--high-value', action='store_true', help='Show only high value targets')
508567
acl_parser.set_defaults(func=cmd_acl)
509568

569+
# Access command
570+
access_parser = subparsers.add_parser('access', help='Find access paths between objects')
571+
access_parser.add_argument('-s', '--source', required=True, help='Source object name')
572+
access_parser.add_argument('-r', '--relation', help='Relation type to filter by')
573+
access_parser.add_argument('-t', '--target', required=True, help='Target object name')
574+
access_parser.add_argument('-d', '--domain', required=True, help='Domain to query')
575+
access_parser.set_defaults(func=cmd_access)
576+
510577
# Upload command
511578
upload_parser = subparsers.add_parser('upload', help='Upload BloodHound data')
512579
upload_parser.add_argument('-f', '--file', help='Path to ZIP file to upload')

0 commit comments

Comments
 (0)