Process ancestry is the backbone of behavioural detection. If you’ve written detection rules for any length of time, you’ve written this one:
Alert when
winword.exespawnspowershell.exe.
It’s a good rule. It’s the canonical example in every detection engineering talk, it catches real macro-based intrusions, and it demonstrates the whole principle neatly — the two processes are individually fine, the relationship is what’s wrong.
It’s also built on an assumption that fails more often than most people realise.
The assumption is that the parent-child relationship you’re reading is true. That when your telemetry says process B’s parent is process A, process A actually created process B. Most of the time that holds. But there are at least four distinct ways it breaks, and an attacker who understands any of them can walk through your rule without touching the rule’s logic at all.
This is a tour of those four process ancestry failure modes, and what you can actually do about each.
What this covers
- Why process ancestry is worth the trouble
- Failure 1: PID reuse
- Failure 2: PPID spoofing
- Failure 3: Orphan reparenting
- Failure 4: The child is not what it says
- What survives all four
- Building a process ancestry model that holds up
- How Logster handles this
Why process ancestry is worth the trouble anyway
Let me not bury the lede: process ancestry remains the single highest-value signal in endpoint detection. Nothing else gives you as much for as little.
The reason is that attackers have enormous freedom over what they run and almost none over what runs it. They can rename the binary, recompile it, pack it, load it reflectively — the file-level indicators all evaporate. But if the initial access was a malicious document, something has to spawn from the document handler. If it was a web shell, something has to spawn from the web server process. That structural constraint is much harder to escape than a hash.
Every individual node on the right is legitimate software. Only the edge is wrong.
So: process ancestry is worth defending. Which means understanding how it fails.
Failure 1: PID reuse
The oldest one, and the most boring, and still responsible for a remarkable number of bad alerts.
PIDs are finite and they get recycled. On Linux the default pid_max is often 32768 — a busy host churns through that in hours. Windows reuses handles-derived PIDs similarly. When PID 4471 exits and a new process later gets PID 4471, any system correlating by PID alone has no way to tell them apart.
Here’s the failure in practice. A malicious process runs as PID 8802 and exits. Twenty minutes later, a routine backup job gets PID 8802. Your correlation logic joins events by PID, and now your incident timeline shows the backup job performing credential access. An analyst spends an hour on a chain of events that never happened.
The inverse is worse and quieter: a genuine chain gets split across a PID boundary and you never see it as one thing.
What to do. Never correlate on PID alone. Always PID plus process start time at minimum — the tuple is effectively unique in practice. If your telemetry gives you a real process GUID, use it; Sysmon’s ProcessGuid exists precisely for this and it’s one of the strongest arguments for Sysmon over rolling your own. On Linux you’re usually constructing the equivalent yourself from (pid, start_time, boot_id).
If you take one thing from this article: go and check whether your pipeline correlates by bare PID. A surprising amount of tooling does.
Failure 2: PPID spoofing
This is the one that breaks the winword.exe → powershell.exe rule directly, and it’s not exotic.
On Windows, CreateProcess accepts an attribute list, and one of the available attributes is PROC_THREAD_ATTRIBUTE_PARENT_PROCESS. Set it, and the process you create is assigned a different parent than the process that actually created it. This is a documented, supported API. It exists for legitimate reasons — UAC elevation and various shell behaviours rely on it.
It also means an attacker with a handle to, say, explorer.exe can spawn PowerShell that reports explorer.exe as its parent, while the actual creating process was the malicious document handler.
Your rule looks at the reported parent. The reported parent is explorer.exe. Nothing fires.
What to do. The spoof changes the reported parent, but it can’t hide the act of spoofing. Setting that attribute requires obtaining a handle to the target parent process with PROCESS_CREATE_PROCESS rights — which shows up as a ProcessAccess event (Sysmon Event ID 10) with a distinctive access mask. A process opening a handle to explorer.exe with process-creation rights, immediately followed by a new child of explorer.exe, is a much stronger signal than the original rule ever was.
More generally: when a primitive is spoofable, detect the spoofing rather than trusting the primitive. That principle generalises well beyond this case.
You can also cross-check. Sysmon’s Event ID 1 reports the parent, but the process’s creation time relative to its supposed parent’s activity, its session and logon ID, and its integrity level often don’t line up under a spoof. None of these is conclusive alone. Together they’re quite hard to fake consistently.
Failure 3: Orphan reparenting (the Linux one)
This one gets Linux detection engineers who came from Windows, and it’s structural rather than adversarial — it happens constantly, with no attacker involved.
On Linux, when a process’s parent exits before it does, the orphan is reparented. Not to nothing — to init, or on modern systems to the nearest subreaper, which is usually systemd. Its PPID becomes 1.
The original ancestry is simply gone. Not hidden, not spoofed — the kernel no longer records it anywhere.
Now consider how daemons are traditionally created. The double-fork:
This is the standard way to daemonise a process on Unix. It’s in every systems programming textbook. And it means that any process that daemonises — legitimately or otherwise — arrives in your telemetry with PPID=1 and no history.
An attacker doesn’t need to do anything clever here. They just need to background a process, or use a tool that daemonises by default, and the chain you were relying on evaporates. nohup, setsid, & with a parent that exits, most malware droppers — all of them produce this.
What to do. You cannot reconstruct this after the fact from a PPID field, because the information no longer exists in the kernel. You have to capture it at fork/exec time and store it yourself. This is one of the strongest practical arguments for event-driven collection over periodic /proc scraping: a /proc walk sees PPID=1 and has no idea what came before, whereas an execve hook saw the real parent at the moment it mattered.
Once you’re capturing it, maintain your own ancestry chain independent of the kernel’s current view. Your recorded lineage stays correct after the kernel’s has been reset to 1.
And treat PPID=1 as information, not as absence of information. A process that daemonised is telling you something. Most of your estate’s PPID=1 processes are started by systemd at boot and are stable and enumerable. One appearing at 3am that isn’t in that set is interesting on its own terms.
Failure 4: The parent is real but the child isn’t what it says
Ancestry can be perfectly accurate and still mislead you, because the identity of the child is wrong.
Process hollowing, NtMapViewOfSection injection, module stomping, and their relatives all produce a process whose image name and path are legitimate — because the process genuinely was created from that legitimate binary — but whose executing code has been replaced. svchost.exe really is svchost.exe on disk. It just isn’t running svchost.exe‘s code any more.
Your process ancestry graph is correct. Every edge is true. And it’s describing a program that isn’t there.
What to do. This is where process ancestry alone runs out and you need to combine it with something else. Memory-region attributes are the usual answer — a private, executable, non-image-backed region in a process that should only be running mapped image code is a strong signal. Sysmon Event ID 8 (CreateRemoteThread) and Event ID 10 (ProcessAccess) with memory-write access masks cover the injection side. Event ID 25 (ProcessTampering) specifically targets hollowing.
The broader point: process ancestry tells you how a process came to exist. It says nothing about what that process is currently doing. Those are different questions and they need different evidence.
What survives all four
Here’s the uncomfortable summary:
| Failure | Ancestry data is… | Detectable? |
|---|---|---|
| PID reuse | Wrong, silently | Yes — use (pid, start_time) or a GUID |
| PPID spoofing | Deliberately falsified | Yes — detect the handle acquisition |
| Orphan reparenting | Genuinely destroyed | Only if captured at exec time |
| Hollowing / injection | Accurate but irrelevant | Yes — but needs memory-level evidence |
Two of these are solved by how you collect. One is solved by what else you collect. One is solved by not trusting a field you can verify another way.
None of them is solved by writing a better rule.
That’s the part I’d most want a detection engineer to take away. The winword.exe → powershell.exe rule isn’t wrong. It’s just resting on infrastructure that has to be correct underneath it, and most of the effort belongs there rather than in the rule.
Building a process ancestry model that holds up
If you’re designing this from scratch, the properties worth insisting on:
Stable process identity. A synthetic ID that never repeats, derived from (pid, start_time, boot_id) or supplied by the sensor. Everything else depends on this.
Lineage captured at creation. Record the parent at execve/CreateProcess time and store it. Never re-derive ancestry from a later PPID read — by then it may have been reparented.
Lineage that outlives the processes. When a parent exits, the recorded relationship should persist. An attack chain frequently has dead intermediate nodes; if your model only knows about currently-running processes, you’ll see the leaf and never the path.
Cross-checks on the reported parent. Creation time ordering, session and logon identity, integrity level. Cheap, and they catch spoofing that the PPID field alone can’t.
Containers as first-class context. In a container, PID 1 is the container’s entrypoint, not the host’s init. Ancestry that crosses the namespace boundary needs to be tracked in both views or it becomes meaningless. Container-native collection matters more than people expect here.
Get those five right and the actual detection logic on top gets dramatically simpler — because you can finally trust the graph.
How Logster handles this
Everything above is the problem statement for what we build, so it is worth being specific rather than stopping at “we have a graph”.
Logster does not read process ancestry out of the kernel when an alert fires. It builds its own process ancestry graph continuously from the telemetry stream, and each of the four failures above is handled at a different layer of it.
Identity first. Nothing is keyed on a PID. Every process becomes a node with a synthetic identifier assigned at creation — (pid, start_time, boot_id) on Linux, the ProcessGuid on Windows. Two processes that held PID 8802 twenty minutes apart are two nodes that never touch. PID reuse stops being a correlation hazard because nothing correlates on PIDs.
Lineage written at exec, not read back later. Collection is event-driven — eBPF hooks in the kernel on Linux, Sysmon and auditd where they are present — so the parent is recorded at the moment of fork and execve, before anything can reset it. When the kernel later reparents an orphan to PID 1, the edge that was true at creation is already in the graph. The double-fork stops erasing history because the history was written before the erase.
Nodes outlive processes. A parent that exits is kept as a node with its edges intact. Attack chains routinely run through dead intermediates; a process ancestry model that only knows about currently-running processes sees the leaf and never the path.
Spoofing becomes an edge rather than a blind spot. Handle acquisitions, remote thread creation and process-access events land on the same graph as the process nodes. A PPID spoof then shows up as a contradiction visible in the structure: a reported-parent edge from explorer.exe sitting beside a PROCESS_CREATE_PROCESS handle edge from the process that actually did the work. The reported parent is still recorded — it is simply no longer the only evidence. The same applies to hollowing and injection, where memory-level events attach to the very node whose identity they undermine.
Then the model reads the graph, not the events. This is the part a rule engine cannot do. Instead of matching one event against a signature, our model works over the assembled subgraph — which nodes connect, in what order, with what timing, and how far that shape deviates from the host’s own established baseline. A winword.exe → powershell.exe edge becomes one feature among many rather than the entire detection, which is what lets it survive all four failures above. What comes out is a scored judgement on whether the host is compromised, mapped to MITRE ATT&CK, with the path through the graph attached so an analyst can see why. Assembling that context — rather than pasting the raw logs into a model — is where most of the engineering actually goes.
None of this removes the need for good telemetry — a graph built from bad collection is a confidently wrong graph. What it changes is where the correctness work lives: in the process ancestry model, built once, rather than in every rule you will ever write against it.
The wider point
Detection engineering discourse spends most of its energy on rules. Which rule catches which technique, how to tune it, how to reduce its false positives.
But a rule is only ever as good as the model it queries. winword.exe → powershell.exe is a one-line rule sitting on top of a large set of assumptions about process identity, lineage accuracy, and collection timing — and every one of those assumptions can fail independently of the rule.
The teams that get good at this stop asking “what rule catches this technique” and start asking “is my process model actually correct.” It’s less interesting work. It’s most of the value.
If you want to go deeper, the docs cover the collection model in detail. And if you think we have got any of the above wrong, we would genuinely like to hear it.