I created my own Bloodhound viewer and you can also do it.
Greetings!
I created my own Bloodhound viewer and you can also do it.
Greetings!
During my pentests and in CTFs I constantly struggled with the bloodhound application, whether if I needed neo4j, I needed mysql, I needed docker. This has made me lost a lot of time and gave me lots of frustrations. That’s why I decided to create my own bloodhound analyzer.
When I was planning this, I noticed there are two ways of doing this:
Easy way:
- Use Claude and forget. Claude would be the one in charge of analyzing the JSON output files from the collector and display the information.
Hard way:
- Create your own tool from scratch and implement your own heuristics.

I went the hard way… This means that I created a tool that takes the bloodhound collector output and with this, I will organize the whole data as nodes and edges to implement heuristics and score-based ranking to identify the most interesting misconfigurations inside an Active Directory environment.
Let’s first start with small goals
My goal in this case would be to implement heuristics to identify interesting ACLs that would lead me to a potential targeted kerberoasting or a password abuse.
Collect Data
The first step is obviously to collect the data from an AD environment. In this case, I used the typical bloodhound-python collector.
bloodhound-python -u '<user>' -p '<password>' -d '<domain.local>' -ns <IP> -gc '<domain.local>' -c All
Organize Data
I noticed that most of the information comes in the following format:
This object -> Has a right -> Over this object
This is great, because it makes our work easier to organize and in that way, we can create a list of dictionaries, as shown below:
[{"from": user1, "to": user2, "type": RightName},
{"from": user2, "to": user3, "type": RightName}]
Get data from files
data = []
for file in files:
with open(file, "r") as f:
content = json.load(f)
data.extend(content.get("data", []))
The score-based ranking will be as following:
- Amount of hops to the domain controller
- “Dangerous” ACL being used
- Privilege of the principal.
Creating edges and nodes
To identify the amount of hops required to get into the domain controller, we need to create some nodes that will save all the target users and mark them as Domain Controller if that is the case.
reverse_graph = defaultdict(list)
nodes = {}
edges = []
with open(userClassificationFile, 'r') as f:
user_data = json.load(f)
for obj in data:
properties = obj.get("Properties", {})
target = properties.get("name")
if not target:
continue
target_norm = target.split("@")[0].upper()
# ensure node exists
nodes.setdefault(target_norm, {"isDomainController": False})
# detect domain controller
if properties.get("primaryGroupID") == 516:
nodes[target_norm]["isDomainController"] = True
if "DOMAIN CONTROLLERS" in properties.get("memberOf", []):
nodes[target_norm]["isDomainController"] = True
if "DC" in target_norm:
nodes[target_norm]["isDomainController"] = True
for ace in aces:
right = ace.get("RightName")
if right not in dangerous_privs:
continue
principal = retrieveNameFromSid(ace.get("PrincipalSID", ""))
if not principal:
continue
principal_norm = principal.split("@")[0].upper()
# Each ACE has a principal, so the principal also becomes a node
nodes.setdefault(principal_norm, {"isDomainController": False})
Example output:
nodes = {
"USER1": {"isDomainController": False},
"DC01": {"isDomainController": True},
}
Creating edges.
As I’m doing score ranking, every edge must have their own score. In this case, I’m just adding the score of every “dangerous” permission.
Reverse_graph is a dictionary where:
- key = a node (the target)
- value = list of nodes that can reach it (the principals)
For example:
edges = [
{"from": "USER1", "to": "ADMIN"},
{"from": "USER2", "to": "ADMIN"},
{"from": "ADMIN", "to": "DC01"},
]
# reverse graph output
{
"ADMIN": ["USER1", "USER2"],
"DC01": ["ADMIN"]
}
score = permissions_baseline.get(right, 0)
# edge score
for obj in user_data:
if obj["User"] == target_norm:
score += obj["Score"]
edge = {
"from": principal_norm,
"to": target_norm,
"type": right,
"score": score
}
edges.append(edge)
reverse_graph[target_norm].append(principal_norm)
return edges, reverse_graph, nodes
Get DC nodes
Now, it’s time to calculate the hops distance to the domain controller, to start off, we need to get all the nodes that are from the Domain Controller. This is just a quick function that returns all the nodes that are true.
def get_dc_nodes(nodes):
return {
node for node, props in nodes.items()
if props.get("isDomainController") is True
}
Main
ASsshown below, the function bfs_with_paths will calculate the distance that takes to arrive into the Domain Controller.
dc_nodes = get_dc_nodes(nodes)
distances, parent = bfs_with_paths(dc_nodes, reverse_graph, edges)
Breadth-First Search
This function:
- Starts from high-value targets (e.g. Domain Controllers)
- Walks backwards through the attack graph.
- Finds who can reach them.
- Keeps track of the path used.
def bfs_with_paths(sources, reverse_graph, edges):
# (from, to) -> edge
edge_lookup = defaultdict(list)
for e in edges:
edge_lookup[(e["from"], e["to"])].append(e)
# Initialize BFS
# sources = starting node
# visited[node] = distance
# parent[node] = how we got there
queue = deque(sources)
visited = {s: 0 for s in sources}
parent = {s: None for s in sources}
while queue:
node = queue.popleft()
for neighbor in reverse_graph.get(node, []):
# Visit new nodes
if neighbor not in visited:
# Record distance
visited[neighbor] = visited[node] + 1
# reversed direction
# Store parent
edge = edge_lookup.get((neighbor, node))
parent[neighbor] = {
"prev": node,
"edge": edge
}
queue.append(neighbor)
return visited, parent
With our bloodhound information mapped, we can now start scoring attack paths by hops and “dangerous” privileges. As shown below, I added a second field called “is_interesting”, this is because there usually are edges with a high score, but come from the domain admin itself, making our output noisy and our detection unusable.
The “is_interesting” parameter aims to identify users that are not from a administrators group.
# Score and classify edges
for edge in edges:
node = edge["from"]
# default
edge["is_interesting"] = False
# If it is already domain admin, skip
if node in TIER0_GROUPS:
continue
# mark interesting if dangerous privilege
if edge["type"] in dangerousPrivs:
edge["is_interesting"] = True
# hop-based scoring
hops = distances.get(node)
if hops is not None:
hop_weight = hops_baseline.get(hops, 0)
edge["score"] = edge.get("score", 0) * hop_weight
Print hops.
for node, hops in distances.items():
path = reconstruct_path_with_edges(node, parent)
formatted = []
for step in path:
if step["edge_type"]:
formatted.append(f"--[{step['edge_type']}]--> {step['node']}")
else:
formatted.append(step["node"])
print(f"{node}: {hops} hops -> " + " ".join(formatted))
print("Max hops:", max(distances.values()))
Chaining the risks. In these last step, we are going to:
- Keep only useful edges
- Turning them into a graph
- Finding 2-step attack paths (chains)
- Score those chains
- Printing the most dangerous ones
# Filter edges for chaining
filtered_edges = [
e for e in edges
if e.get("is_interesting")
and e["from"] not in TIER0_GROUPS
]
# Build adjacency graph
graph = {}
for edge in filtered_edges:
graph.setdefault(edge["from"], []).append(edge)
# Build 2-hop chains
chains = []
for edge1 in filtered_edges:
mid = edge1["to"]
for edge2 in graph.get(mid, []):
if edge2["from"] in TIER0_GROUPS:
continue
chains.append([edge1, edge2])
# Chain scoring
def score_chain(chain):
score = sum(e.get("score", 0) for e in chain)
# reward chaining (pivot potential)
score += 4
# service account bonus
if any(".SVC" in e["to"] for e in chain):
score += 3
# penalize Tier 0 involvement to reduce noise
if any(e["from"] in TIER0_GROUPS for e in chain):
score -= 10
return score
# Rank chains
ranked_chains = sorted(
chains,
key=score_chain,
reverse=True
)
# Output top chains
for chain in ranked_chains[:5]:
print("\nCHAIN:")
for e in chain:
print(f" {e['from']} --[{e['type']}]--> {e['to']}")
Output example
As shown below, there are no interesting hops that we can analyze to get into the Domain Controller, however there are intersting rights upon alt.svc, yorinobu and soulkiller.svc
CERTIFICATE SERVICE DCOM ACCESS: 0 hops -> CERTIFICATE SERVICE DCOM ACCESS
ALLOWED RODC PASSWORD REPLICATION GROUP: 0 hops -> ALLOWED RODC PASSWORD REPLICATION GROUP
DENIED RODC PASSWORD REPLICATION GROUP: 0 hops -> DENIED RODC PASSWORD REPLICATION GROUP
DC01.HACKSMARTER.LOCAL: 0 hops -> DC01.HACKSMARTER.LOCAL
ADMINISTRATORS: 1 hops -> CERTIFICATE SERVICE DCOM ACCESS --[Owns]--> ADMINISTRATORS
DOMAIN ADMINS: 1 hops -> CERTIFICATE SERVICE DCOM ACCESS --[GenericAll]--> DOMAIN ADMINS
ACCOUNT OPERATORS: 1 hops -> CERTIFICATE SERVICE DCOM ACCESS --[GenericAll]--> ACCOUNT OPERATORS
ENTERPRISE ADMINS: 1 hops -> CERTIFICATE SERVICE DCOM ACCESS --[GenericAll]--> ENTERPRISE ADMINS
Max hops: 1
CHAIN:
ACCOUNT OPERATORS --[GenericAll]--> YORINOBU
YORINOBU --[GenericWrite]--> SOULKILLER.SVC
CHAIN:
ACCOUNT OPERATORS --[GenericAll]--> ALT.SVC
ALT.SVC --[GenericAll]--> YORINOBU
CHAIN:
ALT.SVC --[GenericAll]--> YORINOBU
YORINOBU --[GenericWrite]--> SOULKILLER.SVC
CHAIN:
ACCOUNT OPERATORS --[GenericAll]--> RAS AND IAS SERVERS
RAS AND IAS SERVERS --[WriteDacl]--> RAS AND IAS SERVERS ACCESS CHECK
CHAIN:
ACCOUNT OPERATORS --[GenericAll]--> RAS AND IAS SERVERS
RAS AND IAS SERVERS --[WriteOwner]--> RAS AND IAS SERVERS ACCESS CHECK
Github URL
https://github.com/Mr-Rolando7013/ADReader
Future work
Now this implementation is not perfect and it’s still not tested with different environments. For future work:
- Testing
- Graphical interface
- Implementation of AzureHound and RoadRecon
메타데이터
- post_id
- 1f035f67da5b
- slug
- i-created-my-own-bloodhound-viewer-and-you-can-also-do-it-1f035f67da5b
- url
- https://medium.com/@byL0r3t/i-created-my-own-bloodhound-viewer-and-you-can-also-do-it-1f035f67da5b
- canonical_url
- https://medium.com/@byL0r3t/i-created-my-own-bloodhound-viewer-and-you-can-also-do-it-1f035f67da5b
- author_url
- https://medium.com/@byL0r3t
- status
- ok
- fetched_at
- 2026-06-25 12:15:08