Skip to content

How it works

This page follows one real capture from kernel events to an attack story: the benign, attack-shaped chain that the live-ebpf CI job runs on a GitHub-hosted Ubuntu kernel (see ADR 0008). Every artefact shown here is committed: the capture is tests/fixtures/live_chain_ci_v12.jsonl, the result is results/live_ebpf.json and the story is results/live_ebpf_story.mmd.

flowchart TB
  A["1. Sense<br/>bpftrace probe, JSON records"] --> B["2. Normalise<br/>validate, bound, escape"]
  B --> C["3. Graph<br/>process-image vertices,<br/>information-flow edges, hash chain"]
  C --> D["4. Tag<br/>ATT&CK rules + rarity model"]
  C --> E["5. Reduce<br/>lossless edge merging"]
  D --> F["6. Reconstruct<br/>backward to root causes,<br/>forward to blast radius"]
  E --> F
  F --> G["7. Export<br/>story JSON, STIX 2.1,<br/>Mermaid, Cypher"]

1. Sense

The probe (probes/rootline.bt) attaches to process-creation, exec, open, write, dup2/dup3, close, unlink, TCP-connect and exit hooks. It runs with -f json, so every record is one JSON string and a file name with a newline in it cannot fake a second record. These three records are the download and the exec of the dropped script:

{"type": "printf", "data": "tsns=236840773943 kind=openat pid=2902 ppid=2901 uid=1001 flags=O_WRONLY fd=6 path=/tmp/rootline-lab-stage.sh comm=curl\n"}
{"type": "printf", "data": "tsns=236840849236 kind=write pid=2902 ppid=2901 uid=1001 fd=6 path=/tmp/rootline-lab-stage.sh comm=curl\n"}
{"type": "printf", "data": "tsns=236842960472 kind=execve pid=2901 ppid=2899 uid=1001 path=/tmp/rootline-lab-stage.sh comm=rootline-lab-st\n"}

The write record carries a path even though write() only receives a file descriptor: the probe remembers which path each writable fd was opened as (and follows dup2), keyed by process and process start time so a recycled PID never inherits stale entries. The probe excludes its own PID (not its name), and only new thread groups count as new processes.

2. Normalise

rootline.normalize parses each record with an anchored pattern: time, kind, pids and uid are numeric fields in fixed positions, the free-text path comes next and the process name (at most 15 bytes) comes last. A crafted name can therefore garble only its own text, never the event kind or the pids. Control characters are made visible (\x0a), timestamps must be finite, and malformed records are counted as rejected rather than crashing ingestion.

3. Build the provenance graph

Every exec creates a new process-image vertex (curl[2902]), so a PID that runs bash and then the dropped script becomes two vertices (ADR 0001). Edges point the way information flows: a socket sends data to the process that connected, a process writes a file, a file is executed as a process. Every event also extends a SHA-256 hash chain, so rootline verify detects any edit, drop or reorder of the record.

4. Tag

Rules map edge patterns to ATT&CK techniques. On this capture: RL-002 (a file written by a network-facing process is executed from /tmp), RL-004 (credential file read), RL-005 (persistence location written), RL-006 (shell history deleted), RL-003 and RL-007.

5. Reduce

Repeated edges are merged unless new information reached the source in between (the CPR rule), and read-only library noise is pruned. This is lossless for causality by design (ADR 0003); on the 16 ATLAS logs it removes 3.8–5.8× of the edges and leaves every story unchanged.

6. Reconstruct

Pivoting on the stage process, ROOTLINE walks backward only along edges that happened before the influence arrived (King & Chen's time-respecting rule), stops at session roots such as sshd or systemd, ranks entry points, keeps only the causal spine from those entry points to the pivot, then walks forward from the pivot for the blast radius and adds what intrusion-created processes read. One query gives this story (root causes in red):

flowchart LR
  n0[/".config"/]
  n1[/"/dev/null"/]
  n2[/"/etc/gnutls/config"/]
  n3[/"/etc/passwd"/]
  n4[/"/tmp/rl-home/.aws/credentials"/]
  n5[/"/tmp/rl-home/.bash_history"/]
  n6[/"/tmp/rl-home/.config/systemd/user/rootline-lab.service"/]
  n7[/"/tmp/rl-home/lab/cron.d/rootline-lab"/]
  n8[/"/tmp/rootline-lab-stage.sh"/]
  n9[/"/usr/bin/cat"/]
  n10[/"/usr/bin/curl"/]
  n11[/"/usr/bin/mkdir"/]
  n12[/"/usr/bin/rm"/]
  n13[/"lab"/]
  n14[/"systemd"/]
  n15(["rootline-lab-st[2901]"])
  n16(["curl[2902]"])
  n17(["rootline-lab-st[2905]"])
  n18(["cat[2905]"])
  n19(["rootline-lab-st[2906]"])
  n20(["mkdir[2906]"])
  n21(["rootline-lab-st[2907]"])
  n22(["rm[2907]"])
  n23(["rootline-lab-st[2908]"])
  n24(["curl[2908]"])
  n25{{"198.51.100.7:4444"}}
  n26{{"198.51.100.7:8081"}}
  n27{{"[::1]:4445"}}
  n10 -->|executed| n16
  n16 -->|connected| n26
  n26 -->|received| n16
  n16 -->|wrote| n8
  n8 -->|executed| n15
  n8 -->|read| n15
  n15 -->|forked| n17
  n17 -->|wrote| n1
  n17 -->|forked| n18
  n9 -->|executed| n18
  n4 -->|read| n18
  n18 -->|wrote| n1
  n15 -->|forked| n19
  n19 -->|forked| n20
  n11 -->|executed| n20
  n0 -->|read| n20
  n14 -->|read| n20
  n13 -->|read| n20
  n15 -->|wrote| n6
  n15 -->|wrote| n7
  n15 -->|forked| n21
  n21 -->|forked| n22
  n12 -->|executed| n22
  n22 -->|deleted| n5
  n15 -->|connected| n25
  n25 -->|received| n15
  n15 -->|forked| n23
  n23 -->|forked| n24
  n10 -->|executed| n24
  n2 -->|read| n24
  n3 -->|read| n24
  n24 -->|connected| n27
  n27 -->|received| n24
  n24 -->|wrote| n1
  style n8 stroke:#d33,stroke-width:3px
  style n26 stroke:#d33,stroke-width:3px
  style n15 fill:#fdd

The root causes are the dropped script and the socket it was downloaded from (198.51.100.7:8081, a TEST-NET address on a dummy interface inside the runner). The forward slice holds the credential read, the unit and cron-style writes, the deleted history and both listener connections. The relative names .config, lab and systemd are mkdir -p opening path components relative to its cwd; resolving those is a known probe gap.

The Evaluation page measures what each of these components contributes on ATLAS by switching them off one at a time.

7. Export

The story is written as rootline.story/v1 JSON (with the hash-chain head), a STIX 2.1 bundle, Mermaid, and a Neo4j Cypher script in which all telemetry strings are quoted literals. The replay UI steps through the same story event by event.

The same pipeline on a real intrusion: Log4Shell

On the OTRF Log4Shell capture (Sysmon for Linux and AUOMS fused), the reverse-shell alert traces back through java[1340] to the attacker's LDAP (:1389) and HTTP (:8888) callbacks, which is the JNDI exploitation chain:

flowchart LR
  n0[/"/bin/bash"/]
  n1[/"/usr/bin/whoami"/]
  n2(["java[1340]"])
  n3(["bash[17790]"])
  n4(["bash[17794]"])
  n5(["bash[17794]"])
  n6(["bash[17806]"])
  n7(["bash[17806]"])
  n8(["bash[17832]"])
  n9(["whoami[17832]"])
  n10{{"192.168.2.6:1389"}}
  n11{{"192.168.2.6:443"}}
  n12{{"192.168.2.6:8888"}}
  n2 -->|connected| n10
  n10 -->|received| n2
  n2 -->|connected| n12
  n12 -->|received| n2
  n2 -->|forked| n3
  n3 -->|forked| n3
  n0 -->|executed| n3
  n3 -->|forked| n4
  n4 -->|forked| n5
  n0 -->|executed| n5
  n5 -->|forked| n6
  n6 -->|forked| n7
  n0 -->|executed| n7
  n7 -->|connected| n11
  n11 -->|received| n7
  n7 -->|forked| n8
  n8 -->|forked| n9
  n1 -->|executed| n9
  style n12 stroke:#d33,stroke-width:3px
  style n10 stroke:#d33,stroke-width:3px
  style n7 fill:#fdd