BloodHound - Calculating AD metrics (part 2)
BloodHound — Calculating AD metrics 0x02
May 26, 2023

JD

Calculating AD security metrics with BloodHound, Cypher, and a bit of graph analytics…

MATCH Blogpost = (:BloodHound)[:Loves]->(:BlueTeam) RETURN Blogpost.Part2  

Ok, so you’re here for part 2 of a post on BloodHound and AD metrics with Cypher. Really happy to see you came back for more.😃 If you didn’t follow part 1, make sure to read it before continuing with this post, as the Cypher queries we will be looking into kind of build upon the understanding of the queries from part 1.

If you’re good to go, let’s dive right back into it…

No time to explain. Get in back the truck.

So far, we have seen queries to count, rank, and calculate ratios or percentages. Doing so, we’ve touched upon graph theory and graph analytics without really noticing. More info on this branch of mathematics can be found here if you want.

To put it simply, graph theory is the branch of math that studies graphs, and graph analytics are the magic formulas that go with it. Results of these formulas have names (a bit like in Harry Potter).

In this second post, I’ll try to explain in simple words what I understood of some of these, and I’ll share the Cypher query that can be used to calculate them. Most specifically, we will look into:

  • In-Degree: the count of path into X.
  • Out-Degree: the count of path out of X.
  • Betweenness: the count of path to X via Y.

Don’t worry too much if you are not into this whole “math” thing. Our brain is somehow used to reading graphs, and you probably know how to do this quite easily without thinking too much about the math behind it.

A good example is the subway map. I’m sure it doesn’t take you too much time to figure out:

  • Where the central station is.
  • How to get from A to B.
  • How long that trip is.
  • Or even how many other end destinations you can reach from wherever you are on the map.

Well, that’s exactly what we’ll be doing. With our eyes closed, though. Without looking at the map, but by doing some math.

I’ll be using some simple examples to illustrate these concepts. You can also find more info here if you want to know more about the math formulas behind them. The good thing about these calculations (and the Cypher that goes with it), is that they stay valid even when the graph gets too complicated for the human brain. And that comes in handy nicely when dealing with the complexities of Active Directory…

At the end of this second post, I’ll add a part on working over the REST API, and show how you could “Automate All The [BloodHound] Things!!” for attack path management. I’ll show how the REST API works, how you can use CypherDog for the job, and of course what the Cypher queries have to look like for that.

For now, let’s pick it up just where we left it: measuring stuff… And let’s start with the out-degree.

DISCLAIMER: Again, I’m not an expert, so apologies if this is not exactly how things work in the world of graph theory. This is how I understood it so far. Still learning…

3. Out-degree

The first concept we will touch upon is out-degree. Or, to put it in simple words, we could say the “reach”. It answers the following question: how many nodes can I reach from $here?

In non-directed graphs, where edges do not have a direction, we would be talking about the (degree of) centrality of a node. In the case of directed graphs, where edges have a direction, the degree of centrality is split into two parts:
1) In-degree, for the count of paths into a node, and
2) out-degree, for the count of paths out of a node.

Now you also know that “The 6 degrees of Kevin Bacon” has nothing to do with a cold winter.

In real life, (Bob)-[:Likes]->(Alice) is not the same as (Alice)-[:Likes]->(Bob). Ask Bob, he knows. Graphing this type of relationship would require a Directed Graph. Two-way relationships in directed graphs simply require two edges in opposite directions (for example,”TrustedBy” in BloodHound). On the other hand, something like (A)-[:IsLinkedTo]-(B) would not require a directed graph.

The nature of relationships between nodes (directional? Y/N) determines the type of graph we are working with. In BloodHound, all Edges are directional (the attack cannot be performed the other way around), and so we are dealing with a directed graph. BloodHound graphs can be displayed in a non-directed view by pressing the graph icon on the right. Most of the time, the directed view (left to right) is more readable for attack paths. Now let’s see how this works with a basic example.

And guess what? We’ve kind of covered that type of calculation already in the last post.

In this previous example:

// Top 5 Users with the most Group memberships.
MATCH (x)-[:MemberOf*1..]->(y)
WITH x.name AS User, COUNT(y) AS MembershipCount
RETURN User, MembershipCount
ORDER BY MembershipCount DESC
LIMIT 5

As mentioned in part 1 of this blog, it is possible to use chains of Edges (and that’s what we did with nested Group memberships), but it is of course also possible with chains of different Edges.

So, let’s take the following as an example:

// All Groups with reach to Computer, with percentage.
MATCH (y:Computer)
CALL {WITH y RETURN COLLECT(y) as ttl}
MATCH (x:Group)
OPTIONAL MATCH p=shortestPath((x)-[*1..]->(y))
WITH COUNT(p) AS count, x,Count(ttl) as ttl
RETURN x.name AS Source,count AS Reach,ttl 
AS Total,toFloat(count)/toFloat(ttl)*100.0 AS Pct
╒════════════════════════════════════════════════════════╤═══════╤═══════╤═════╕
│"Source""Reach""Total""Pct"│
╞════════════════════════════════════════════════════════╪═══════╪═══════╪═════╡
│"DOMAIN [email protected]"519519100.0│
├────────────────────────────────────────────────────────┼───────┼───────┼─────┤
│"ENTERPRISE [email protected]"519519100.0│
├────────────────────────────────────────────────────────┼───────┼───────┼─────┤
│"ACCOUNT [email protected]"519519100.0│
├────────────────────────────────────────────────────────┼───────┼───────┼─────┤
│"KEY [email protected]"519519100.0│
├────────────────────────────────────────────────────────┼───────┼───────┼─────┤
│"ENTERPRISE KEY [email protected]"519519100.0│
├────────────────────────────────────────────────────────┼───────┼───────┼─────┤
│"[email protected]"519519100.0│
├────────────────────────────────────────────────────────┼───────┼───────┼─────┤
│"DOMAIN [email protected]"519519100.0│
├────────────────────────────────────────────────────────┼───────┼───────┼─────┤
│"DOMAIN [email protected]"05190.0  │
├────────────────────────────────────────────────────────┼───────┼───────┼─────┤
│"DOMAIN [email protected]"05190.0  │
├────────────────────────────────────────────────────────┼───────┼───────┼─────┤
[...]

And there you have it. Computer reach for all Groups in one go, with percentage. With just six lines of Cypher. Notice the use of an OPTIONAL MATCH clause to return 0 in case there is no path.

4. In-degree

If you understood how the out-degree works, the in-degree will be a breeze. It’s the exact same idea, but this time we count the number of paths into a node.

A classical BloodHound example for this would be how many users have path to DA:

// Exposure of the DA Group.
CALL {MATCH (all:User) RETURN COUNT(all) AS Total}
MATCH (x:User)
MATCH (y:Group) WHERE y.objectid ENDS WITH '-512'
MATCH p=shortestPath((x)-[*1..]->(y))
WITH y.name AS Target, COUNT(p) AS Count, Total
RETURN Target, Count, Total,
    round(Count/toFloat(Total)*100,2) AS Pct
╒══════════════════════════════╤═══════╤═══════╤═════╕
│"Target""Count""Total""Pct"│
╞══════════════════════════════╪═══════╪═══════╪═════╡
│"DOMAIN [email protected]"125072.37 │
└──────────────────────────────┴───────┴───────┴─────┘

And just like that, we could do this same exposure calculation for all high-value Groups:

// Exposure of HighValue Groups.
CALL {MATCH (all:User) RETURN COUNT(all) AS Total}
MATCH (x:User)
MATCH (y:Group {highvalue:true})
MATCH p=shortestPath((x)-[*1..]->(y))
WITH y.name AS Target, COUNT(p) AS Count, Total
RETURN Target, Count, Total,
    round(Count/toFloat(Total)*100,2) AS Pct
╒═══════════════════════════════════╤═══════╤═══════╤═════╕
│"Target""Count""Total""Pct"│
╞═══════════════════════════════════╪═══════╪═══════╪═════╡
│"DOMAIN [email protected]"125072.37 │
├───────────────────────────────────┼───────┼───────┼─────┤
│"ENTERPRISE [email protected]"125072.37 │
├───────────────────────────────────┼───────┼───────┼─────┤
│"ACCOUNT [email protected]"125072.37 │
├───────────────────────────────────┼───────┼───────┼─────┤
│"[email protected]"125072.37 │
├───────────────────────────────────┼───────┼───────┼─────┤
│"DOMAIN [email protected]"125072.37 │
├───────────────────────────────────┼───────┼───────┼─────┤
│"BACKUP [email protected]"125072.37 │
├───────────────────────────────────┼───────┼───────┼─────┤
│"PRINT [email protected]"125072.37 │
├───────────────────────────────────┼───────┼───────┼─────┤
│"SERVER [email protected]"125072.37 │
└───────────────────────────────────┴───────┴───────┴─────┘

And let’s also add the average distance while we are at it, because … why not?

// Exposure - top 5 Groups.
CALL {MATCH (all:User) RETURN COUNT(all) AS Total}
MATCH (x:User)
MATCH (y:Group {highvalue:true})
MATCH p=shortestPath((x)-[*1..]->(y))
WITH y.name AS Target, COUNT(p) AS Count, Total,
    COLLECT(length(p)) AS lengthList
RETURN Target, Count, Total,
    round(Count/toFloat(Total)*100,2) AS Pct,
    round(reduce(s=0,l in lengthList|s+l)/toFloat(SIZE(lengthList)),2) AS avgDist
ORDER BY Pct DESC
LIMIT 5
╒═══════════════════════════════════╤═══════╤═══════╤═════╤═════════╕
│"Target""Count""Total""Pct""avgDist"│
╞═══════════════════════════════════╪═══════╪═══════╪═════╪═════════╡
│"ENTERPRISE [email protected]"125072.374.08     │
├───────────────────────────────────┼───────┼───────┼─────┼─────────┤
│"ACCOUNT [email protected]"125072.374.58     │
├───────────────────────────────────┼───────┼───────┼─────┼─────────┤
│"[email protected]"125072.374.75     │
├───────────────────────────────────┼───────┼───────┼─────┼─────────┤
│"DOMAIN [email protected]"125072.375.08     │
├───────────────────────────────────┼───────┼───────┼─────┼─────────┤
│"DOMAIN [email protected]"125072.374.75     │
└───────────────────────────────────┴───────┴───────┴─────┴─────────┘

Now that we have seen both in and out, let’s see how we could combine them into a single query: Centrality I/O – Domain Admin (Wald0’s I/O):

// Wald0 I/O - DA.
CALL {MATCH (allU:User) RETURN COUNT(allU) AS TotalU}
CALL {MATCH (allC:Computer) RETURN COUNT(allC) AS TotalC}
MATCH (y:Group) WHERE y.objectid ENDS WITH '-512'
CALL {WITH y
  OPTIONAL MATCH pIN=shortestPath((x:User)-[*1..]->(y)) RETURN x
  }
CALL {WITH y
  OPTIONAL MATCH pOUT=shortestPath((y)-[*1..]->(z:Computer)) RETURN z
  }
WITH DISTINCT y.name AS Target, TotalU, TotalC,
  COUNT(DISTINCT(x)) AS CountIN,
  COUNT(DISTINCT(z)) AS CountOUT
RETURN Target,
  CountIN, TotalU, round(CountIN/toFloat(TotalU)*100,1) AS PctIN,
  CountOUT,TotalC, round(CountOUT/toFloat(TotalC)*100,1) AS PctOUT
╒══════════════════════════════╤═════════╤════════╤═══════╤══════════╤════════╤════════╕
│"Target""CountIN""TotalU""PctIN""CountOUT""TotalC""PctOUT"│
╞══════════════════════════════╪═════════╪════════╪═══════╪══════════╪════════╪════════╡
│"DOMAIN [email protected]"125072.4519519100.0   │
└──────────────────────────────┴─────────┴────────┴───────┴──────────┴────────┴────────┘

NOTE: As a rule of thumb, Groups with high out-degree should have a low in-degree.

But why limit ourselves to a single Group? How about Wald0’s I/O of all Groups in a single shot:

// Wald0 I/O - All Groups.
CALL {MATCH (allU:User) RETURN COUNT(allU) AS TotalU}
CALL {MATCH (allC:Computer) RETURN COUNT(allC) AS TotalC}
MATCH (y:Group)
CALL {WITH y
  OPTIONAL MATCH pIN=shortestPath((x:User)-[*1..]->(y)) RETURN x
  }
CALL {WITH y
  OPTIONAL MATCH pOUT=shortestPath((y)-[*1..]->(z:Computer)) RETURN z
  }
WITH DISTINCT y.name AS Target, TotalU, TotalC,
  COUNT(DISTINCT(x)) AS CountIN,
  COUNT(DISTINCT(z)) AS CountOUT
RETURN Target,
  CountIN, TotalU, round(CountIN/toFloat(TotalU)*100,1) AS PctIN,
  CountOUT,TotalC, round(CountOUT/toFloat(TotalC)*100,1) AS PctOUT
╒══════════════════════════════════════════════╤═════════╤════════╤═══════╤══════════╤════════╤════════╕
│"Target""CountIN""TotalU""PctIN""CountOUT""TotalC""PctOUT"│
╞══════════════════════════════════════════════╪═════════╪════════╪═══════╪══════════╪════════╪════════╡
│"DOMAIN [email protected]"125072.4519519100.0   │
├──────────────────────────────────────────────┼─────────┼────────┼───────┼──────────┼────────┼────────┤
│"ENTERPRISE [email protected]"125072.4519519100.0   │
├──────────────────────────────────────────────┼─────────┼────────┼───────┼──────────┼────────┼────────┤
│"ACCOUNT [email protected]"125072.4519519100.0   │
├──────────────────────────────────────────────┼─────────┼────────┼───────┼──────────┼────────┼────────┤
│"KEY [email protected]"125072.4519519100.0   │
├──────────────────────────────────────────────┼─────────┼────────┼───────┼──────────┼────────┼────────┤
│"ENTERPRISE KEY [email protected]"125072.4519519100.0   │
├──────────────────────────────────────────────┼─────────┼────────┼───────┼──────────┼────────┼────────┤
│"[email protected]"125072.4519519100.0   │
├──────────────────────────────────────────────┼─────────┼────────┼───────┼──────────┼────────┼────────┤
│"DOMAIN [email protected]"125072.4519519100.0   │
├──────────────────────────────────────────────┼─────────┼────────┼───────┼──────────┼────────┼────────┤
│"DOMAIN [email protected]"10650720.905190.0     │
├──────────────────────────────────────────────┼─────────┼────────┼───────┼──────────┼────────┼────────┤
│"DOMAIN [email protected]"507507100.005190.0     │
├──────────────────────────────────────────────┼─────────┼────────┼───────┼──────────┼────────┼────────┤
[...]

All we had to do is remove the WHERE clause from the previous query (WHERE y.objectid ENDS WITH ‘-512’). Cypher is quite powerful, no? 🤯

Ok, but now what if we want to know what happens in the middle of the graph? Somewhere between the start nodes and targets of our paths. 

5. Betweenness

This last piece of graph analytics is my favorite: the degree of betweenness. It answers the following question: how many paths to X go via Y?

Before we have a look at the Cypher query for it, let’s take a moment to visualize how it works (using the same example graph as in the previous example). Let’s again imagine we have Users and Groups and (whatever) Edges connecting them:

What we want to do, is to count how many user paths go via each of the nodes on the graph. To achieve this in our current example, we could look at our graph like a metro map:

By putting a different color on each ‘metro line’ (= each user path), we can start to see how this calculation works. And it gets even more obvious in the next view:

If you get used to switching BloodHound graph viewing mode in your head, calculating this node ‘weight’ (aka ‘chokepoint’ or ‘degree of betweenness’) becomes easier.

What we actually need to do, because we are looking at a path into something, is to simply count how many of these paths come into a given node on the way to target.

In very simple words: take all the nodes of all the individual (overlapped) paths we are looking at, remove the start node, put them in one big bag. At that point, we just need to see how many times each of them is in the bag and compare that to the number of paths.

To achieve this type of calculation in Cypher, we could do something like this:

// Top 10 ChokePoints for User-to-DA.
MATCH (x:User)
MATCH (y:Group) WHERE y.objectid ENDS WITH '-512'
MATCH p=shortestPath((x)-[*1..]->(y))
UNWIND TAIL(NODES(p)) AS allnodes
WITH DISTINCT allnodes AS node,
    COUNT(allnodes) AS count,
    [lbl IN LABELS(allnodes) WHERE lbl<>'Base'][0] AS label
RETURN node.name AS Name,label AS Label,count AS Weight
ORDER BY count DESCENDING
LIMIT 10

This query basically does:

  • MATCH desired source, target and allowed path.
  • UNWIND path NODES() as table key, skipping the first one with TAIL().
  • WITH DISTINCT to “Group By” node.
  • Then does a COUNT() how many are in each group (of unique).
  • Add source LABELS() for completeness.
  • RETURN what we need.
  • ORDER BY & LIMIT for top n.

That’s all there is to it. The output looks something like this:

╒══════════════════════════════════╤══════════╤════════╕
│"Name""Label""Weight"│
╞══════════════════════════════════╪══════════╪════════╡
│"DOMAIN [email protected]""Group"12      │
├──────────────────────────────────┼──────────┼────────┤
│"ENTERPRISE [email protected]""Group"7       │
├──────────────────────────────────┼──────────┼────────┤
│"[email protected]""User"6       │
├──────────────────────────────────┼──────────┼────────┤
│"[email protected]""User"4       │
├──────────────────────────────────┼──────────┼────────┤
│"[email protected]""User"4       │
├──────────────────────────────────┼──────────┼────────┤
│"PC0397.WHISPERER.LABZ""Computer"4       │
├──────────────────────────────────┼──────────┼────────┤
│"PC0101.WHISPERER.LABZ""Computer"3       │
├──────────────────────────────────┼──────────┼────────┤
│"[email protected]""User"2       │
├──────────────────────────────────┼──────────┼────────┤
│"[email protected]""OU"2       │
├──────────────────────────────────┼──────────┼────────┤
│"ACCOUNT [email protected]""Group"1       │
└──────────────────────────────────┴──────────┴────────┘

NOTE: If we wanted to do the same calculation for a path on the way out (some kind of in-betweenness vs. out-betweenness if you like), we would need to count all nodes on each path, except the last one this time. This Cypher combo UNWIND Reverse(TAIL(Reverse(NODES(p)))) AS allnodes could be used to extract them.

In a last effort, just to make our count more meaningful, we could add a percentage next to our previous output:

// Chokepoints with relative weight in percentage.
MATCH (y:Group) WHERE y.objectid ENDS WITH '-512'
CALL {WITH y
    MATCH p=shortestPath((:User)-[*1..]->(y))
    RETURN COUNT(p) AS PathCount
    }
MATCH p=shortestPath((x:User)-[*1..]->(y))
UNWIND TAIL(NODES(p)) AS allnodes
WITH DISTINCT allnodes AS node,
    COUNT(allnodes) AS count,
    [lbl IN LABELS(allnodes) WHERE lbl<>'Base'][0] AS label,
    PathCount
RETURN node.name AS Name,label AS Label,count AS Weight,
    Round(count/toFloat(PathCount)*100,2) AS Pct
ORDER BY count DESCENDING
LIMIT 10
╒══════════════════════════════════╤══════════╤════════╤═════╕
│"Name""Label""Weight""Pct"│
╞══════════════════════════════════╪══════════╪════════╪═════╡
│"DOMAIN [email protected]""Group"12100.0│
├──────────────────────────────────┼──────────┼────────┼─────┤
│"ENTERPRISE [email protected]""Group"758.33│
├──────────────────────────────────┼──────────┼────────┼─────┤
│"[email protected]""User"650.0 │
├──────────────────────────────────┼──────────┼────────┼─────┤
│"[email protected]""User"433.33│
├──────────────────────────────────┼──────────┼────────┼─────┤
│"[email protected]""User"433.33│
├──────────────────────────────────┼──────────┼────────┼─────┤
│"PC0397.WHISPERER.LABZ""Computer"433.33│
├──────────────────────────────────┼──────────┼────────┼─────┤
│"PC0101.WHISPERER.LABZ""Computer"325.0 │
├──────────────────────────────────┼──────────┼────────┼─────┤
│"[email protected]""User"216.67│
├──────────────────────────────────┼──────────┼────────┼─────┤
│"[email protected]""OU"216.67│
├──────────────────────────────────┼──────────┼────────┼─────┤
│"ACCOUNT [email protected]""Group"18.33 │
└──────────────────────────────────┴──────────┴────────┴─────┘

You might be tempted to run that query for all high-value groups or even all groups, and it would work. But you have to be careful. The numbers would have to be counted (and interpreted) slightly differently. A bit like putting each target on a separate layer, doing what we did above for each node of each path on each layer, adding the list of nodes from each path, from each layer in that one big bag, and counting how many times each node appears in total. Remember to divide by the number of layers when calculating the percentage. :thinking_face: Not sure I’m being clear here. Anyway, you will get the feeling of it when you play with it. This might be getting too complicated for now. And that’s our next topic.

When things get too complicated for my Cypher kung-fu, I switch to my favorite glue (aka PowerShell), and work with BloodHound over the REST API. Asking several simple questions is sometimes a better option than asking a very complex one.

6. REST API and automation

Neo4j REST API

So, we now have seen a number of queries we could run against any AD that would return a lot of tables and numbers for your next report. But at some point all this pasting into the UI and pasting again the output somewhere else becomes a pain. So you might want to automate all this. To do this, we can work over the neo4j REST API . I’ll skip on some introduction bits (documented here if needed), and I’ll just give an example of how to make the calls. I’ll be using PowerShell, my mother language, but you could easily translate this to one of your choosing.

By default, Neo4j will allow API calls from localhost only. You must tweak the Neo4j configuration to allow remote queries, if needed.

IMPORTANT: By default (and in our example below), we are making http calls. Make sure to configure https where needed.

#############################################
## BloodHound: Cypher Queries via REST API ##
#############################################
# VARS
$IP, $Port, $DB = 'localhost', '7474', 'neo4j'
$Usr, $Psswrd = 'neo4j', 'bloodhound'
# QUERY
$Query = 'MATCH (x) RETURN COUNT(x)'
# POST
$Uri   = "http://${IP}:$Port/db/$DB/tx/commit"
$Token = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("${Usr}:$Psswrd"))
$Head  = @{
    'Accept' = 'application/json; charset=UTF-8'
    'Content-Type'  = 'application/json'
    'Authorization' = "Basic $Token"
    }
$Body  = @{statements=@(@{statement=$Query})} | convertto-Json
$Call  = Invoke-RestMethod -Uri $Uri -Method POST -Headers $Head -body $Body
# OUTPUT
If($Call.Errors){Write-Error $Call.errors.Message}
else{$Call.results.data.row}

Our call to the API returns JSON objects and the PowerShell Invoke-RestMethod takes care of unpacking all that for us. The output is immediately ready to be piped into whatever other cmdlet you would want to push them into, or to be stored in a variable for further manipulation. Awesomeness.

You could easily wrap this bit of code into a parameterized cmdlet to make your life even easier. The good news is, I already did that for you, and it’s called CypherDog.

Invoke-CypherDog

Since I use this tool, I only use the BloodHound UI to import my data and export screenshots. All the rest can be done from the command line via the API. You can find this (really great) tool here if you want. Enough auto-promo… let’s take this puppy for a walk:

## PowerShell
## Load Script
. ./CypherDog.ps1
## Create Session
# Creds
$cred = Get-Credential
# Session
New-CypherDogSession -Credential $cred -Verbose -CypherToClip $true
## You should be good to go...
##### Basic Commands:
## Node
Node User -limit 1Node User -limit 1 -cypher
## Edge [= Path with single edge type]
Edge User MemberOf Group * 'DOMAIN [email protected]'
## Path
Path User Group * 'DOMAIN [email protected]' -FilterEdge NoAzure
##### Advanced:
## Whatever custom Cypher...
Cypher "MATCH (x) RETURN COUNT(x)"

I won’t go into too much details about the three first commands, but they all have a help page to check their syntax; and tab-completion on node names to ease the pain. You can use -where, -With, -Return followed by Cypher syntax with all three [ex: -where “x.name=’Bob'”]. All three cmdlets have a -Cypher switch that will display the matching Cypher query, instead of invoking it. This query gets added to your clipboard if you added the optional-CypherToClip $true when creating your CypherDog Session.

The one command we will use today is the Cypher cmdlet (alias for Invoke-Neo4jCypher). This is the one that all other commands use under the hood, and since you are now a cypher metrics ninja, this is the one you will be using for the job.

# PowerShell
PS> Cypher "MATCH (x) RETURN COUNT(x)"
1154

All you have to do is pass a Cypher query as a string to the Cypher command. Thought you might be disappointed with the output of most of them if you RETURN like we did so far, as only the values are returned. It’s a mess.

# PowerShell
PS> Cypher "RETURN 'Bob' AS Name, 44 AS Age"
Bob
44

To return nice objects from the API, we need to RETURN our objects in a single column – even the complex ones. So how do we do this? RETURN objects in Maps.

To show you how that looks, I’ll use only a few of the queries we have seen in the post, but you could adapt any of them. Let’s first have a look at a basic example:

Before:

// Simple output (Neo4j browser).
RETURN 'Bob' AS Name, 44 AS Age

After:

// Map output (REST API).
RETURN {Name:'Bob',Age:44} AS Map

The map output looks like this in the Neo4j browser: 😕

╒═══════════════════════╕
│"Map"                  │
╞═══════════════════════╡
│{"Age":44,"Name":"Bob"}│
└───────────────────────┘

But with CypherDog (or the REST API), we would get exactly what we want: 😃

# PowerShell
PS> Cypher "RETURN {Name:'Bob',Age:44}"
 Age Name
--- ----
 44 Bob

Now that we know how it works, we can tweak any of our previous queries to return nice objects over the API:

Computer OS percentage / map output (API)

// Computer/OS percentage (API).
CALL {MATCH (all:Computer) RETURN COUNT(all) AS Total}
MATCH (x:Computer)
WITH DISTINCT x.operatingsystem AS OS, Total,
  COUNT(x) AS Count,
  ROUND(COUNT(x)/toFloat(Total)*100,1) AS Pct
RETURN {OS:OS,Count:Count,Total:Total,Pct:Pct}
ORDER BY OS
  Pct OS                              Total Count
  --- --                              ----- -----
22.70 Windows 10 Enterprise             519   118
20.40 Windows 10 Professional           519   106
19.10 Windows 11 Enterprise             519    99
17.30 Windows 11 Professional           519    90
17.50 Windows 8.1 Enterprise            519    91
 0.40 Windows Server 2019 Data Center   519     2
 0.40 Windows Server 2019 DataCenter    519     2
 0.60 Windows Server 2019 Standard      519     3
 0.60 Windows Server 2022 Data Center   519     3
 1.00 Windows Server 2022 Standard      519     5

Top 10 Chokepoints User-to-DA / map output (API)

// Top 10 ChokePoints User-to-DA (API).
MATCH (x:User)
MATCH (y:Group) WHERE y.objectid ENDS WITH '-512'
MATCH p=shortestPath((x)-[*1..]->(y))
UNWIND TAIL(NODES(p)) AS allnodes
WITH DISTINCT allnodes AS node,
    COUNT(allnodes) AS count,
    [lbl IN LABELS(allnodes) WHERE lbl<>'Base'][0] AS label
RETURN {Name:node.name,Label:label,Weight:count}
ORDER BY count DESCENDING
LIMIT 10
Label    Weight Name
-----    ------ ----
Group        12 DOMAIN [email protected]
Group         7 ENTERPRISE [email protected]
User          6 [email protected]
User          4 [email protected]
User          4 [email protected]
Computer      4 PC0397.WHISPERER.LABZ 
Computer      3 PC0101.WHISPERER.LABZ 
User          2 [email protected]
OU            2 [email protected]
Group         1 ACCOUNT [email protected]

As you can see in the above two examples, the only thing we need to change is the RETURN… I guess you got it by now, so I’ll let you adapt the ones you like.

NOTE: Use Cypher $PreviousQuery | select Label,Name,Weight to return properties in the order you want. I don’t think there is a way to enforce that server-side.

If you are curious about what a path could look like when returned in the browser, check this one out:

// Output path "a la CypherDog" (browser).
CALL {
  MATCH p=shortestPath((:User)-[*1..]->(:Group{name:'DOMAIN [email protected]'}))
  RETURN p ORDER BY LENGTH(p) LIMIT 1000
  }
WITH COUNT(p) as countp, COLLECT(p) as colp
UNWIND range(0,countp-1) AS pid
WITH pid as PathId,
	colp[pid] AS Path,
	LENGTH(colp[pid]) AS PathLength,
    [n IN NODES(colp[pid])|[l IN LABELS(n) WHERE l <> 'Base'][0]] AS labelcol,
    [c IN NODES(colp[pid]) | c.name] AS namecol,
    [c IN RELATIONSHIPS(colp[pid])|TYPE(c)] as edgecol
WITH PathId,PathLength,namecol,labelcol,edgecol
UNWIND range(0,PathLength-1) AS StepID
RETURN PathId,PathLength,StepID,
    PathLength-StepID AS Distance,
    labelcol[StepID] AS SourceType,
    namecol[StepID] AS SourceName,
    edgecol[StepID] AS Edge,
    labelcol[StepID+1] AS TargetType,
    namecol[StepID+1] AS TargetName
ORDER BY PathLength
╒════════╤════════════╤════════╤══════════╤════════════╤══════════════════════════════════╤════════════╤════════════╤══════════════════════════════════╕
│"PathId""PathLength""StepID""Distance""SourceType""SourceName""Edge""TargetType""TargetName"                      │
╞════════╪════════════╪════════╪══════════╪════════════╪══════════════════════════════════╪════════════╪════════════╪══════════════════════════════════╡
│0101"User""[email protected]""MemberOf""Group""DOMAIN [email protected]"    │
├────────┼────────────┼────────┼──────────┼────────────┼──────────────────────────────────┼────────────┼────────────┼──────────────────────────────────┤
│1202"User""[email protected]""Owns""Group""ENTERPRISE [email protected]"│
├────────┼────────────┼────────┼──────────┼────────────┼──────────────────────────────────┼────────────┼────────────┼──────────────────────────────────┤
│1211"Group""ENTERPRISE [email protected]""WriteDacl""Group""DOMAIN [email protected]"    │
├────────┼────────────┼────────┼──────────┼────────────┼──────────────────────────────────┼────────────┼────────────┼──────────────────────────────────┤
│2303"User""[email protected]""Owns""Computer""PC0397.WHISPERER.LABZ"           │
├────────┼────────────┼────────┼──────────┼────────────┼──────────────────────────────────┼────────────┼────────────┼──────────────────────────────────┤
│2312"Computer""PC0397.WHISPERER.LABZ""HasSession""User""[email protected]"    │
├────────┼────────────┼────────┼──────────┼────────────┼──────────────────────────────────┼────────────┼────────────┼──────────────────────────────────┤
│2321"User""[email protected]""MemberOf""Group""DOMAIN [email protected]"    │
├────────┼────────────┼────────┼──────────┼────────────┼──────────────────────────────────┼────────────┼────────────┼──────────────────────────────────┤
│3303"User""[email protected]""Owns""User""[email protected]"  │
├────────┼────────────┼────────┼──────────┼────────────┼──────────────────────────────────┼────────────┼────────────┼──────────────────────────────────┤
[...]

This is the format CypherDog uses for Path and Edge commands. And if you really think about it: if you were to pipe that type of output to | Group-Object TargetName you would magically have the “nodeweight” calculation for all nodes on that graph… 🧙

# Powershell
PS> path user group -TargetWhere "y.objectid ENDS WITH '-512'" |
     group-object target -noelement |
     sort-object count -descending
Count Name
----- ----
   12 DOMAIN ADMINS@WHISPERER.…
    7 ENTERPRISE ADMINS@WHISPE…
    6 SHARON_STEPHENS@WHISPERE…
    4 ADMINISTRATOR@WHISPERER.…
    4 PC0397.WHISPERER.LABZ
    4 DOROTHY_PETERSON@WHISPER…
    3 PC0101.WHISPERER.LABZ
    2 KYLE_BAILEY@WHISPERER.LA…
    2 WASHINGTONDC@WHISPERER.L…
    1 EASTERN@WHISPERER.LABZ

 Automate, mate

If you could get me those bloodhound metrics into an excel chart and paste that in a powerpoint... that would be really awesome...

We now have Cypher queries, and a way to run them from the command-line. The next logical step would be to automate all this:

  • Build a Cypher library of useful metrics you want to collect.
  • Automate execution of those queries via the REST API.
  • Output objects.
  • (Manipulate and) format output as desired.

Once this is all working the way you want:

  • Repeat collection / analysis / remediation in cycles.
  • Compare metrics to the previous cycle.

And that’s it! You’ve automated a big chunk of your BloodHound metrics calculation for attack path management, and you now have historical data. The next step could be to push all these metrics to your SIEM.

Or why not even update your BloodHound database from that same SIEM instead of re-collecting? But that’s another story… for which I made a proof-of-concept (called FalconHound) and will perhaps share another blog post at some point…

 

Outro

Ok, that’s about it for this post. I hope you found some useful bits in there. Now you can go back to work and say something like:

”Currently, 71% of all users have a path to DA. Should an attacker gain access to any of these accounts, they have access to 100% of the infra hosting our crown jewels.”

You should at least get some attention… And if you add:

“… Removing this unneeded ACL would fix 80% of the problem, bringing the numbers down to an acceptable count of 14 users left with a path, out of which 9 are DAs, who need this for their daily work and are strictly monitored. For the 5 remaining… [and so on]

Who knows? You might get some traction. I’d be curious to hear how it went, let me know… 😉

By the time you read this post, the BloodHound workshop I was preparing for is a thing of the past already. Make sure to check out announcements on the @FalconForceTeam Twitter or https://falconforce.nl/events if you wish to attend the next public iteration, and dive even deeper into the powers of BloodHound and Cypher.

Feel free to drop us a line at [email protected] in case you want to discuss BloodHound and Cypher for your company!

Hope you enjoyed the post.

See you next time, and until then: happy graphing!

@SadProcessor

Credits & final meme:

If you use BloodHound and you really 💙 it, check out the official BloodHound swag shop. All benefits go to charity, so don’t hesitate: donate (and look great)!

 

If you bump into CptJesus at a conference or in a bar:

– Thank him for BloodHound, and do not hesitate to ask for an autograph. He will even sign your phone if you take a selfie with him.

Attackers think in autographs

 

 

And if you ever find wald0 anywhere:

– Thank him for BloodHound, but don’t drink with him. This path ends late in a karaoke bar…

 

Knowledge center

Other articles

Together. Secure. Today.

Stay in the loop and sign up to our newsletter

FalconForce realizes ambitions by working closely with its customers in a methodical manner, improving their security in the digital domain.

Energieweg 3
3542 DZ Utrecht
The Netherlands

FalconForce B.V.
[email protected]
(+31) 85 044 93 34

KVK 76682307
BTW NL860745314B01