Adjacency List to Manage P2P Implants

In Command & Control (C2) parlance, there are two main types of implant: egress and (peer-to-peer) P2P. An egress implant will talk directly to attacker-controlled infrastructure over a protocol such as HTTP. A P2P implant does not talk directly to an attacker, but has their communications (SMB, TCP, or whatever) relayed through one or more other implants. C2 frameworks also often provide a visualisation of the P2P mesh, such as the example below.

Image credit: https://www.cobaltstrike.com/

When building your own C2 framework, you may wonder how managing a P2P mesh (or graph) could be accomplished. This post will provide some possibilities in C#.

Consider the following structures:

A -> B -> C -> D

E -> F -> G -> H
     |
     | -> I -> J
          |
          | -> K

To begin with, we have a single egress implant, A. This implant is connected to B, which is connected to C, which is connected to D. Next, we have a more complex structure where egress implant E is connected to H via F and G. F is also connected to I and J; and I is also connected to K.

How can we manage such a structure? The use of an adjacency list is one possibility and has the benefit of being quite simple. The requirement is that we record each vertex and any associated edges (a connection to another vertex).

We can start off with a simple generic class.

public class Graph<T>
{
    private readonly Dictionary<T, HashSet<T>> _adjacencyList = new();
}

We use the vertex as the dictionary key and the hashset contains its immediate neighbour(s). The following methods can be used to add new vertices and edges.

public void AddVertex(T vertex)
{
    if (!_adjacencyList.ContainsKey(vertex))
        _adjacencyList.Add(vertex, new HashSet<T>());
}

public void AddEdge(T start, T end)
{
    if (_adjacencyList.TryGetValue(start, out var edges))
        edges.Add(end);
}

To construct the first structure, we can do:

var graph = new Graph<string>();

// add vertices
graph.AddVertex("A");
graph.AddVertex("B");
graph.AddVertex("C");
graph.AddVertex("D");

// add edges
graph.AddEdge("A", "B");  // A -> B
graph.AddEdge("B", "C");  // B -> C
graph.AddEdge("C", "D");  // C -> D

And the second structure can be built like this:

graph.AddVertex("E");
graph.AddVertex("F");
graph.AddVertex("G");
graph.AddVertex("H");
graph.AddVertex("I");
graph.AddVertex("J");
graph.AddVertex("K");

graph.AddEdge("E", "F");
graph.AddEdge("F", "G");
graph.AddEdge("G", "H");
graph.AddEdge("F", "I");
graph.AddEdge("I", "J");
graph.AddEdge("I", "K");

Depth-First Searching

One thing we might want to do is find every vertex that is reachable from a given starting point. If that starting point is A, it needs to return B, C, and D. If it was E, it needs to return F, G, H, I, J and K.

A depth-first search could accomplish that:

public IEnumerable<T> DepthFirstSearch(T start)
{
    var visited = new HashSet<T>();

    if (!_adjacencyList.ContainsKey(start))
        return visited;

    var stack = new Stack<T>();
    stack.Push(start);

    while (stack.Count > 0)
    {
        var vertex = stack.Pop();

        if (visited.Contains(vertex))
            continue;

        visited.Add(vertex);

        foreach (var neighbour in _adjacencyList[vertex])
            if (!visited.Contains(neighbour))
                stack.Push(neighbour);
    }

    return visited;
}

This works by starting at the specified vertex and adding it to a stack (a LIFO collection). The edges (neighbours) of that vertex are iterated over and also added to the stack. The “visited” hashset is used to track vertices that have already been checked. We keep looping until there is nothing left in the stack and the IEnumerable returned is simply a collection of every vertex we could visit from the starting vertex.

private static void PrintVertexes<T>(IEnumerable<T> search)
{
    var vertexes = search.ToArray();
    
    for (var i = 0; i < vertexes.Length; i++)
    {
        Console.Write(i == vertexes.Length - 1
            ? $"{vertexes[i]}"
            : $"{vertexes[i]}, ");
    }

    Console.WriteLine();
}

var s1 = graph.DepthFirstSearch("A").ToArray();
var s2 = graph.DepthFirstSearch("E").ToArray();

PrintVertexes(s1);
PrintVertexes(s2);
A, B, C, D
E, F, I, K, J, G, H

This is called a depth-first search because, as you can see from the output, from F, it prefers to go down the routes to I and K before backtracking to J and then G and H. You can replace the stack with a queue (which is FIFO rather than LIFO) and this would perform a breadth-first search.

The same data would be returned, but in a different order. Specifically E, F, G, I, H, J, K.

PathExists

We may want to know if a path exists between two specific vertices. For this, we can search until the start and end match (or if they never do, a path doesn’t exist).

public bool PathExists(T start, T end)
{
    var visited = new HashSet<T>();

    if (!_adjacencyList.ContainsKey(start))
        return false;

    var stack = new Stack<T>();
    stack.Push(start);

    while (stack.Count > 0)
    {
        var vertex = stack.Pop();

        if (visited.Contains(vertex))
            continue;

        if (vertex.Equals(end))  // if the current vertex equals our "end" vertex, a path exists
            return true;

        visited.Add(vertex);

        foreach (var neighbour in _adjacencyList[vertex])
            if (!visited.Contains(neighbour))
                stack.Push(neighbour);
    }

    return false;
}

Console.WriteLine("A -> D: {0}", graph.PathExists("A", "D"));
Console.WriteLine("F -> J: {0}", graph.PathExists("F", "J"));
Console.WriteLine("C -> G: {0}", graph.PathExists("C", "G"));
A -> D: True
F -> J: True
C -> G: False

Path Finding

Instead of just knowing every vertex that is reachable from a starting vertex, it would be great if we could find a path between two specific vertices. Finding a way to do this was a bit of a brain melt. Let’s explain it with an example.

We know that the path from E to J should be E, F, I, J. We start by searching the graph (as above), but storing how we get to each vertex. Searching from vertex E, there are 6:

  • F from E
  • G from F
  • I from F
  • J from I
  • K from I
  • H from G

Our destination vertex is J, which we know is reachable from I. I we can get to from F; and F from E. Since we’re working backwards, we simply reverse the path at the end.

public IEnumerable<T> FindPath(T start, T end)
{
    var map = new Dictionary<T, T>();

    var queue = new Stack<T>();
    queue.Push(start);

    while (queue.Count > 0)
    {
        var vertex = queue.Pop();

        foreach (var neighbour in _adjacencyList[vertex])
        {
            if (map.ContainsKey(neighbour))
                continue;

            map[neighbour] = vertex;
            queue.Push(neighbour);
        }
    }

    var path = new List<T>();
    var current = end;

    while (!current.Equals(start))
    {
        path.Add(current);
        current = map[current];
    }

    path.Add(start);
    path.Reverse();

    return path;
}

PrintVertexes(graph.FindPath("E", "J"));
E, F, I, J

Visualisation

There’s not much to say here as there’s a plethora of graphing libraries that one can use. Here’s an example of a graph I plotted using Microsoft Automatic Graph Layout.

Conclusion

This post demonstrated how to leverage an adjacency list to provide simple management of a P2P graph. There are improvements one can make such as adding detection for cyclical edges and removing vertices and edges, but hopefully this was enough to get you started. The same idea can be applied to other languages depending on what data collection methods they provide.

, ,

Related posts

ANYSIZE_ARRAY in C#

There are multiple structures in Windows that contain fixed sized arrays. The instance...

SafeHandle vs IntPtr

C# is a popular language in both the commercial space (think ASP.NET Core, MVC,...

C# Source Generators

Introduction

C# Source Generators made their first appearance around the release of .NET 5 and...

Latest posts

ANYSIZE_ARRAY in C#

There are multiple structures in Windows that contain fixed sized arrays. The instance...

SafeHandle vs IntPtr

C# is a popular language in both the commercial space (think ASP.NET Core, MVC,...

C# Source Generators

Introduction

C# Source Generators made their first appearance around the release of .NET 5 and...