Getting Started with AstraeaDB

Hunting Lateral Movement With Embeddings and Time Travel

On this page

Find the account that appeared somewhere it had never been, then reconstruct when it happened.

The previous post ended on a negative result: ranking machines by importance finds your servers, not your intruder. This post finds the intruder. The change is not a cleverer algorithm. It is looking at behaviour over time rather than at structure.

About this data

data/lanl/auth.csv needs a plain warning before you read anything into it.

The 25 red-team events are real, taken from the dataset's own ground-truth labels: real accounts, real machines, real timestamps. The benign events around them are synthetic, generated by data/lanl/make_auth.py from the real DNS graph, because the genuine LANL authentication file is tens of gigabytes and is not something to ship in a documentation repository.

So the intrusion is authentic and the crowd it hides in is manufactured. The script says so at the top and is deterministic, so you can read exactly how the background was made. Real authentication data is messier than this, and a hunt against it is correspondingly harder.

Building the authentication graph

Two node types and one edge, with the event time recorded on the edge:

import csv
import collections

from astraeadb import AstraeaClient

auth = list(csv.reader(open("data/lanl/auth.csv")))
redteam = list(csv.reader(open("data/lanl/redteam.csv")))
attack_start = min(int(row[0]) for row in redteam)

client = AstraeaClient(host="127.0.0.1", port=7687)
client.connect()

nodes = {}
def node(label, name):
    if (label, name) not in nodes:
        nodes[(label, name)] = client.create_node([label], {"name": name})
    return nodes[(label, name)]

for time, user, source, dest in auth:
    client.create_edge(
        node("User", user), node("Computer", source), "AUTH_FROM",
        {"dest": dest},
        valid_from=int(time),      # the edge exists from this moment onward
    )

print(f"{len(auth)} events, {len(nodes)} nodes, attack begins at t={attack_start}")

valid_from is the important argument. It records when the relationship came into existence, which turns the graph into something you can ask questions of as it was rather than only as it is.

The simple query first

Before reaching for anything sophisticated, ask the obvious question. People sit at their own machines. An account appearing on more than one is worth a look:

machines_per_user = collections.defaultdict(set)
for _, user, source, _ in auth:
    machines_per_user[user].add(source)

suspects = {u: sorted(m) for u, m in machines_per_user.items() if len(m) > 1}
print(f"{len(suspects)} of {len(machines_per_user)} accounts used more than one machine")
for user, machines in sorted(suspects.items()):
    print(f"   {user:12} {machines}")

Five accounts out of roughly twelve hundred. Now check them against the labels:

known_bad = {row[1] for row in redteam}
print("every suspect is a known red-team account:", set(suspects) <= known_bad)
print("red-team accounts we found:", len(set(suspects) & known_bad), "of", len(known_bad))

Every one, and all of them. Perfect precision and perfect recall, from counting.

That is worth sitting with, because it is the most useful habit in this post. The graph made the question easy to ask, but the answer needed no embeddings, no traversal and no model. Try the boring query first. If it works you are finished, and if it does not you have learned something about the shape of the problem before spending anything.

Time travel: when did it change?

Counting told you who. It cannot tell you when, and "when" is what turns a suspicion into an incident report.

Because every edge carries a valid_from, you can ask what the graph looked like at any moment. neighbors_at takes a timestamp and answers as of then:

def machines_at(user, when):
    hops = client.neighbors_at(nodes[("User", user)], "outgoing", when, "AUTH_FROM")
    return sorted({client.get_node(h["node_id"])["properties"]["name"] for h in hops})


user = "U748@DOM1"
print(f"{user} just before the intrusion:", machines_at(user, attack_start - 1))
print(f"{user} after it:                 ", machines_at(user, 244910))

One machine before, three after. That is the whole story of a stolen credential in two lines: the account was working normally somewhere, and then it turned up in two places it had never been.

Do it for all five and you have a timeline:

for user in sorted(suspects):
    before = machines_at(user, attack_start - 1)
    after = machines_at(user, 244910)
    arrived = [m for m in after if m not in before]
    print(f"{user:12} home={before}  appeared on={arrived}")

Every account has exactly one home machine before the intrusion, and every one of them turns up on C17693. One also reaches C18025. That is the attacker's foothold and their pivot, recovered from the data rather than assumed.

Behaviour, for when you have no labels

The two techniques above worked because this graph is small and the anomaly is stark. Both leaned on knowing what to count.

The harder question is the one you face on a real network: which machines behave like this one, when you have no labels and cannot enumerate the rules in advance. That is what embeddings are for. Describe each machine's behaviour in a sentence, embed the sentence, and let similarity do the comparing:

import json
import os
import urllib.request

OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")


def embed(texts):
    body = json.dumps({"model": "embeddinggemma", "input": texts}).encode()
    req = urllib.request.Request(f"{OLLAMA_URL}/api/embed", data=body,
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=60) as resp:
        return json.load(resp)["embeddings"]


users_per_machine = collections.defaultdict(set)
dests_per_machine = collections.defaultdict(set)
for _, user, source, dest in auth:
    users_per_machine[source].add(user)
    dests_per_machine[source].add(dest)

profiles = {
    machine: (f"A machine used by {len(users)} distinct accounts, "
              f"reaching {len(dests_per_machine[machine])} destinations.")
    for machine, users in users_per_machine.items()
}

names = sorted(profiles)
for name, vec in zip(names, embed([profiles[n] for n in names])):
    client.create_edge(node("Computer", name),
                       node("Profile", f"profile:{name}"), "DESCRIBED_BY")
    client.get_node(node("Profile", f"profile:{name}"))   # keep the id warm

print("described", len(profiles), "machines")
print("C17693:", profiles["C17693"])

The profile for C17693 reads differently from every other machine's, because five accounts using one machine is a description that nothing else in the population matches. On a real network you would embed richer profiles, covering which services a machine reaches, at what hours, and how that compares with the machines around it, then look for the ones whose description has drifted from their own past.

The point is the shape of the technique rather than this particular sentence: turn behaviour into text, turn text into a position, and let distance find the things you would not have known to grep for.

client.close()

What this hunt did and did not prove

It found five compromised accounts with perfect precision, dated the intrusion, and named the attacker's two machines, all checkable against labels the dataset's authors provided.

It did that against a background that was manufactured to be well behaved. The honest reading is that the techniques are sound and the difficulty was removed. On real telemetry the counting query would return hundreds of accounts using more than one machine, most of them administrators, shared workstations and service accounts, and the work would be in separating those from the one that matters. The graph does not remove that work. It makes each attempt cheap enough to try.

What's next

You have a suspicion, a timeline, and the machines involved. In Explaining an Investigation: GraphRAG for Audit-Grade Reports, you will turn that into something a person can read and an auditor can check, where every sentence traces back to a node in the graph.