← Back to list

I created my own Bloodhound viewer and you can also do it.

Greetings!

byL0r3t · 2026-05-04 08:29 · 1 claps · 5.5 min read
#cybersecurity #red-team #red-team-tools #ctf
Open on Medium ↗
Wiki topics: SAF · Safety & Alignment 🔒 · Cybersecurity

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