Tuning an abnormal child-process rule when you have no telemetry to tune against
Parent-child anomaly rules are some of the highest-value detections you can write, and also the ones that drown you in false positives. Here is how we scope one when there is no production EDR telemetry to learn the benign baseline from: read the parent binary itself.
Parent-child process anomalies are some of the best detections we get for free from process-creation telemetry, and also some of the fastest to turn into a wall of false positives. The idea is simple: a system binary that should never spawn a shell, spawning a shell, is worth looking at. wermgr.exe launching cmd.exe. notepad.exe launching powershell.exe. services.exe launching something with a network beacon. The trouble is the second half of the sentence, the part where we have to know which child processes are legitimate, and that normally means weeks of production telemetry to learn the baseline. Plenty of teams do not have that. This is how we build the rule anyway.
The problem with learning the baseline from the fleet
The textbook approach is to collect Sysmon event ID 1 across the estate for a month, aggregate every parent-child pair we observe, and treat the frequent ones as benign. It works when we have the telemetry and the volume. It falls apart in a small shop, on a new deployment, or in an environment where the interesting hosts are exactly the ones with the thinnest logging. It also has a nasty blind spot: if the attacker was already resident during the learning window, we cheerfully allowlist their behavior as normal. Learning benign from the fleet assumes the fleet was clean, and we do not always get to assume that.
So we flip it. Instead of asking the fleet what child processes a binary produces, we ask the binary itself. A Windows executable is fairly honest about what it can do, and if we read it statically we can build a defensible allowlist without a single day of production telemetry. Let us use the classic case: something masquerading as WerFault.exe, the Windows Error Reporting binary, via process hollowing or an equivalent injection. Masquerading is T1036, the injection under it is usually T1055, and both of them end with a familiar-looking process doing something the real one never does.
Read the imports before you write the rule
The first thing we pull is the import table. We drop the binary into a PE viewer (pestudio, PE-bear, or dumpbin /imports if we are on the command line) and look past the generic modules. kernel32.dll, ntdll.dll, and user32.dll are on almost everything and tell us nothing. What we want are the modules specific to this binary’s job. Real WerFault.exe pulls in wer.dll and faultrep.dll, the error-reporting libraries. Those imports describe what the process is genuinely there to do, and by extension the narrow set of things it has any reason to launch.
(This is also, incidentally, a decent sanity check on a suspicious sample: a WerFault.exe on disk that does not import the error-reporting DLLs is already lying to us before it runs.)
Strings and the process-creation APIs tell you the children
Next we walk the strings and the API references. We are hunting for two things. First, executable names embedded in the binary, because a process that legitimately shells out usually names the thing it shells out to somewhere in its own image. Second, and this is the part that decides the rule, the process-creation APIs: CreateProcessA and CreateProcessW, CreateProcessAsUserW, ShellExecuteExW, WinExec. If a binary imports none of the process-creation surface, it has no business being the parent of anything, and a child process under it in production is by definition abnormal. That is a rule we can write with high confidence and no baseline at all.
If it does import CreateProcess, we cross-reference which child names appear in the strings and, where we can, trace what actually gets passed to those calls. For the error-reporting family the honest answer is that its legitimate children are a very short, very specific list of Windows components. Anything outside that list, and especially an interactive shell or a scripting host, is the catch. We are not guessing at benign from volume, we are reading it out of the binary.
Turning that into the detection
The rule that comes out of this is a Sysmon EID 1 (or Windows Security 4688, if that is the process-creation telemetry we have) condition on the parent image being our masqueraded binary and the child image being anything outside the short allowlist we just derived. Because the allowlist came from the binary rather than the fleet, it is portable: it holds on a brand-new endpoint, on the quiet finance laptop, and on the host that was already compromised when we deployed, none of which a fleet-learned baseline can promise. This is the same instinct behind hunting for processes launching from suspicious paths, where we let the location and lineage carry the signal rather than trusting an image name. And it slots directly into the primitive-first way of designing a detection: the primitive here is “system utility spawns an interactive child,” and reading the binary is how we prove that primitive is rare.
What the method misses, so you ship it honestly
This catches a lot. It does not catch everything, and pretending otherwise is how we lose the analyst’s trust the first time it fails. Reading the parent binary tells us about children created by that binary directly. It says nothing about an injected thread inside the parent that spawns a child through a different code path than the original image ever used, which is precisely what good hollowing arranges. If the injected code calls CreateProcess itself, our parent-child rule still fires, because Windows still records the lineage. If it stays entirely in-process, or reaches out through a helper it starts by handle inheritance, we can miss it, and we should pair this with an image-load or memory-integrity signal to cover that. Static analysis of the parent is a strong, telemetry-free starting point for the common case. It is a floor, not a ceiling, and the honest rule is the one that says so in its own documentation.
One more habit that has saved us: when we finally do get some production telemetry, even a week of it from a handful of hosts, we validate the binary-derived allowlist against reality rather than replacing it. If the fleet shows a legitimate child we did not predict from the imports, we add it and note why. If the fleet contradicts nothing, the confidence in the rule goes up and we can tighten it. The static read gets us a rule on day one, and the telemetry, when it arrives, refines a detection we already trust instead of being the only thing standing between us and the attack.
detection-engineeringsysmonprocess-injectionthreat-hunting