Tuesday, October 27, 2026

, , ,

Process trees in Livehunt: YARA rules that know who launched what



Note: YARA-X v1.21.0 adds a parent_name field to the items in vt.behaviour.processes_list, and v1.22.0 adds ancestor_names. VirusTotal now fills that list for every sandbox report that includes a process tree, by flattening it. The result is one entry per process, and each entry knows the name of the process that created it and of the processes above it. Read the YARA-X blog.

If you have written Livehunt rules on top of sandbox behavior, you have probably used vt.behaviour.processes_created. It is a flat list of command lines. It tells you that mshta.exe ran and that powershell.exe ran, but not that one started the other. For a lot of detections that is the whole point: PowerShell on its own is noise, PowerShell launched by mshta.exe is not.
Our sandboxes have always recorded the full process tree. The problem was getting it into YARA, which has no recursive types. As of this month, every file with sandbox reports that reaches Livehunt carries vt.behaviour.processes_list: one entry per process, from every sandbox that ran the file, and each entry knows the name of its parent, the names of its ancestors, and the files it touched.
This post covers what is in each entry, what to keep in mind when writing rules against it, and five cases we worked through while preparing it, each with the rules we wrote and tested. Every case comes with at least one hash, so you can paste the rule in the Livehunt editor and test it yourself.
If you haven't read it yet, we recommend our earlier post It's all about the structure! Creating YARA rules by clicking. It shows how to open the Structure view of any file in the Livehunt editor and turn fields into rule conditions with a click, which is handy for everything that follows.

What each process entry carries

Field Type What it holds
name string The process, as the sandbox reports it. Usually the image path plus the command line.
parent_name string The name of the process that created this one. Empty for root processes and when the parent is unknown.
ancestor_names array of strings The names of its ancestors, nearest first: ancestor_names[0] is the parent (the same as parent_name), ancestor_names[1] the grandparent, and so on. Empty for root processes and when the parent is unknown.
process_id / parent_process_id string PIDs, as reported by the sandbox.
start_time / termination_time integer When the process started and ended, if known.
files_written array of strings Files this process created or modified.
files_opened array of strings Files this process opened.
files_deleted array of strings Files this process deleted.
files_copied[x].source / .destination string Files this process copied, when the sandbox attributes copies to a process.
The fields that change how you write rules are parent_name, ancestor_names and the per-process file lists. With them, "PowerShell wrote a .dll" becomes "the PowerShell that cmd.exe started wrote a .dll".
The simplest possible rule looks like this:
import "vt"

rule cmd_spawns_schtasks_create {
  condition:
    for any p in vt.behaviour.processes_list : (
      p.name icontains "schtasks" and
      p.name icontains "/create" and
      p.parent_name icontains "cmd.exe"
    )
}

Things to know before you write rules

Names are not normalized. Each sandbox writes process names its own way. CAPE gives the quoted command line, Zenbox prepends the image path to the command line, Tencent HABO gives only the executable name, and C2AE and Microsoft Sysinternals often leave environment variables unexpanded (%ComSpec% /c ...). Use icontains and match on the executable name, not on a full path with == or startswith.
Link processes by name, not by PID. A file is usually run by several sandboxes, and Livehunt puts all their processes in the same list. PID 1040 can be cmd.exe in one report and explorer.exe in another. To go more than one level up, use ancestor_names: it is built from each sandbox report on its own, so a chain never mixes processes from different sandboxes. For long chains, you can also climb by name: look for the entry whose name is equal to the parent_name you already have. Both strings come from the same sandbox report, so an exact == is safe there.
A practical workflow is to search first and write the rule second. Intelligence search already supports behavior_processes:"<command line>", which matches anywhere in the list with no hierarchy. It is a quick way to collect candidates. Then you write the rule with the parent condition, and test it against those hashes in the Livehunt editor.

Case 1: porting a Sigma rule, and what it misses

Sigma's Suspicious MSHTA Child Process (03cc0c25-389f-4bf8-b48d-11878079f1ca) is a good example of a detection that needs the parent. Stripped down, it says: the parent image ends with \mshta.exe and the child is a shell or a scripting host.
detection:
    selection_parent:
        ParentImage|endswith: '\mshta.exe'
    selection_child:

        - Image|endswith:
              - '\cmd.exe'
              - '\powershell.exe'
              - '\pwsh.exe'
              - '\wscript.exe'
              - '\cscript.exe'
              - '\sh.exe'
              - '\bash.exe'
              - '\reg.exe'
              - '\regsvr32.exe'
              - '\bitsadmin.exe'
    condition: all of selection*
The Livehunt version maps ParentImage to parent_name and Image to name:
import "vt"

rule mshta_spawns_shell {
  meta:
    description = "Port of Sigma 03cc0c25 (Suspicious MSHTA Child Process)"
  condition:
    for any p in vt.behaviour.processes_list : (
      p.parent_name icontains "mshta.exe" and
      (
        p.name icontains "cmd.exe" or
        p.name icontains "powershell" or
        p.name icontains "pwsh" or
        p.name icontains "wscript" or
        p.name icontains "cscript" or
        p.name icontains "bash.exe" or
        p.name icontains "reg.exe" or
        p.name icontains "regsvr32" or
        p.name icontains "bitsadmin"
      )
    )
}
One thing does not carry over. Sigma also checks OriginalFileName to catch renamed binaries, and sandbox process names have no equivalent. A renamed mshta.exe will get past this rule.
We tested it against d30a0a6c09f664446943d82982f0630e85c447cbe84be8a74b6db6d66effbe80, an Excel file named NOVI USD 44570.00 952PLA3262852510.xls (18/76, tagged cve-2017-0199). It matches. What is more interesting is the CAPE process tree behind the match:
Process Parent
EXCEL.EXE "...\attachment.xls" /dde Explorer.EXE
mshta.exe -Embedding svchost.exe -k DcomLaunch -p
cmd.exe "/c POwERSHeLl.Exe ... -EX BypaSS -nOp -w 1 -ec ..." mshta.exe -Embedding
POwERSHeLl.Exe ... -ec ... cmd.exe
WScript.exe "...\AppData\Roaming\westingcostringforme.vbe" POwERSHeLl.Exe
powershell.exe -nop -ep bypass -file "C:\Temp\XBRYINEZ.ps1" WScript.exe
aspnet_compiler.exe powershell.exe -file ...XBRYINEZ.ps1
Excel is not the parent of mshta.exe. The exploit has Office request an object through DCOM, so mshta.exe is started by the DCOM launcher service, and it shows up with -Embedding. A rule that looks for "Office spawns mshta" would never fire on this document. What we can do is walk one level up from the shell and check the grandparent:
import "vt"

rule dcom_mshta_spawns_shell {
  condition:
    for any p in vt.behaviour.processes_list : (
      (p.name icontains "cmd.exe" or p.name icontains "powershell") and
      p.parent_name icontains "mshta.exe" and
      p.ancestor_names[1] icontains "svchost.exe" and
      p.ancestor_names[1] icontains "dcomlaunch"
    )
}
The first entry of ancestor_names is always the parent, so ancestor_names[1] is the grandparent: the process that started mshta.exe. Without it, you need a second loop over the list to find the entry whose name is the shell's parent_name, and one more loop for every extra level.
The same rule matches other spreadsheets from the same wave, for example 8d249972358268b7a00e182e3f1ecf93ec8bbbee65d22ef4d8a3c382783c1cfa (PO#3302-20260520002.xls), 2d199776cc50b9ad548132105e95af86b29ec8ebca03895a7ddecde58587306b (Request Quotation.xls) and 7c8a3c8366b3106db56359e3defc654d365ce4de21d0a7b1fbad02fbb2aeae52. The rest of that chain shows up again in cases 4 and 5.

Case 2: a services.exe that is not, and the copies behind it

On a normal Windows system, services.exe is started by wininit.exe and by nothing else. A services.exe with any other parent deserves a look:
import "vt"

rule fake_services_wrong_parent {
  condition:
    for any p in vt.behaviour.processes_list : (
      p.name iendswith "\\services.exe" and
      p.parent_name != "" and
      not p.parent_name icontains "wininit.exe"
    )
}
The parent_name != "" check matters. In the reports we looked at, the real services.exe shows up as a root process with no parent, and we don't want those to match.
Our first version also covered lsass.exe. We tested it against 43 recent, clean, signed files, and it matched 19 of them. In those reports the sandbox records lsass.exe with services.exe as its parent. Whatever the reason, that is what the data says, so we dropped lsass.exe from the rule. The lesson is a general one: before encoding what the parent "should" be, check what the sandboxes actually report for clean files.
The rule matches fe8357febe314becf31510a7a2ecb0369e312ad58a448292f5f8a09075d7694c, a .NET executable (45/76) submitted as Cd5KK.exe. In the Jujubox report, Cd5KK.exe starts C:\Windows\System32\NlsLexicons0c1a\services.exe. That process writes two .vbs files to %TEMP% and runs each one with WScript.exe, and one of the scripts starts the fake services.exe again. The sample also launches one PowerShell per path to add Defender exclusions, and in the CAPE report it creates ONLOGON scheduled tasks through WMI, with wmiprvse.exe as the parent of schtasks.exe.
Where did the fake services.exe come from? Jujubox does not attribute file copies to a process, but it does report them at the summary level, in vt.behaviour.files_copied. Cd5KK.exe copied itself to eight places, each one with the name of a Windows binary:
C:\Windows\System32\NlsLexicons0c1a\services.exe
C:\System Volume Information\services.exe
C:\Windows\system\smss.exe
C:\Windows\System32\mfc42\taskeng.exe
C:\Users\Default User\WmiPrvSE.exe
C:\Program Files\Common Files\System\ado\en-US\WmiPrvSE.exe
C:\PerfLogs\Admin\sysmon.exe
C:\Users\<USER>\Downloads\patchregistry\event-stream.exe
You can combine both fields. This rule looks for a copy named after a system process, and then for a process in the list that runs from exactly that path and has a parent:
import "vt"

rule copy_with_system_name_is_executed {
  condition:
    for any c in vt.behaviour.files_copied : (
      (
        c.destination iendswith "\\services.exe" or
        c.destination iendswith "\\smss.exe" or
        c.destination iendswith "\\lsass.exe" or
        c.destination iendswith "\\csrss.exe" or
        c.destination iendswith "\\svchost.exe" or
        c.destination iendswith "\\wmiprvse.exe" or
        c.destination iendswith "\\taskhostw.exe" or
        c.destination iendswith "\\conhost.exe"
      ) and
      for any p in vt.behaviour.processes_list : (
        p.name icontains c.destination and
        p.parent_name != ""
      )
    )
}
The copy proves the file was planted, and the process entry proves it ran. We tested the rule against 30 recent files with a services.exe path in their Jujubox file activity: it matched 9 of them, among them 4930cc4e7e3e258bf2815793f0e5698bdd45adfae8e052bb1dc4c05c6689ade6, which shares the trojan.msil/basic label with Cd5KK.exe and also matches the services.exe parent rule above. It did not match any of the 43 clean files.

Case 3: headless consoles, renamed or not

conhost.exe --headless runs a console program without showing a window. Sigma has a rule that looks for --headless and powershell in the conhost.exe command line. With the parent available, we can ask a slightly different question: did a PowerShell process actually start with a headless console as its parent?
import "vt"

rule headless_console_runs_powershell {
  condition:
    for any p in vt.behaviour.processes_list : (
      (p.name icontains "powershell" or p.name icontains "pwsh") and
      p.parent_name icontains "--headless"
    )
}
A plain search shows why a rule on the command line alone gets noisy. behavior_processes:"conhost.exe --headless" fs:2026-09-01+ returned 947 files when we ran it. Part of that is legitimate software. Terminal emulators use pseudo-consoles, which start conhost.exe --headless --inheritcursor ... --server .... herdr.exe (c18555a6bb74cd021198b57d16b93b40e74e7d0f8f0c28a45f0a4cc22dea0f8b, 0/76) does exactly that, and the rule above does not match it, because that conhost.exe never starts a shell.
On the malicious side, 5c2ceaec797dfa79e81cde6d765898ee8102357e280fa322db6e5a4b0ce3fc4a is a Windows shortcut with a Korean-language lure about penalty guidelines for e-commerce law violations, named with a double .hwp.lnk extension (34/76). CAPE and Jujubox both show the same chain: opening the shortcut starts conhost.exe --headless powershell.exe -NoP -exec BYpass -w 1 -C $rukp = '...', and that console starts the PowerShell that runs the Base64 blob. The decoded script opens with a function called Invoke-EnvironmentCheck.
Notice that the rule checks for --headless in the parent, not for conhost.exe. That is deliberate. 77da8a02d71078564409863c358a544e981b9643c8f50c1daabc5d5d176e3c75 (42/76) drops a file called rhpmx63snl.exe into %APPDATA%\MicrosoftWorker_e08ec640d\. That file is 51bb5aeef2e48dd14fae764f80dc67f516df9914ea9c350329e229f6cea919a2, an unmodified Microsoft conhost.exe (original name CONHOST.EXE, 0/77) with a random name. It later runs as rhpmx63snl.exe --headless powershell.exe -w h -ep bypass -c & "...\xd24mgasgb4.exe". A rule that required conhost.exe in the parent would miss it. If you only want the renamed case, add not p.parent_name icontains "conhost".

Case 4: persistence that actually ran

Spotting schtasks /create is easy. Knowing whether the task did anything during the analysis is harder, and the process list can answer it. When a scheduled task fires, the new process has the Task Scheduler service as its parent (svchost.exe -k netsvcs -p -s Schedule, or taskeng.exe on older systems). If that process runs a file the sample wrote earlier, the persistence worked.
import "vt"

rule task_scheduler_runs_dropped_file {
  condition:
    for any run in vt.behaviour.processes_list : (
      (
        run.parent_name icontains "-s schedule" or
        run.parent_name icontains "taskeng.exe"
      ) and
      for any dropper in vt.behaviour.processes_list : (
        for any f in dropper.files_written : (
          (f iendswith ".exe" or f iendswith ".dll" or f iendswith ".vbs" or
           f iendswith ".js" or f iendswith ".ps1" or f iendswith ".bat" or
           f iendswith ".cmd") and
          run.name icontains f
        )
      )
    )
}
This is the first rule in the post that uses files_written, and it uses it to link two processes that are not parent and child: the one that wrote the file, and the one the scheduler started from it.
Back to 77da8a02d71078564409863c358a544e981b9643c8f50c1daabc5d5d176e3c75. In the CAPE report, software.exe writes rhpmx63snl.exe and xd24mgasgb4.exe to %APPDATA%\MicrosoftWorker_e08ec640d\, and then runs:
schtasks.exe /create /sc minute /mo 1 /tn "AdobeSecurityManager" /tr "C:\Users\<USER>\AppData\Roaming\MicrosoftWorker_e08ec640d\rhpmx63snl.exe --headless powershell.exe -w h -ep bypass -c & '...\xd24mgasgb4.exe'"
When the task fires, the scheduler starts rhpmx63snl.exe (the renamed conhost.exe from case 3), which starts PowerShell, which starts xd24mgasgb4.exe. Every link in that sentence is a parent_name, and the rule matches. The whole chain is also in the ancestor_names of xd24mgasgb4.exe: the PowerShell, rhpmx63snl.exe, the Schedule service and services.exe.
Zenbox saw the same task fire, but reported rhpmx63snl.exe as a root process with no parent. That is fine: CAPE has the link, and the rule only needs one sandbox to see it. It also shows why you shouldn't write rules that need every sandbox to agree.
One more format issue we ran into. a549a6c8c01da1d03e8da663eec70997a5bd5207e665d8e23f6fa75804dafc06, a VBS file we use in the next case, also creates a task that fires during the VMRay analysis, with taskeng.exe as the parent. The rule misses it, because VMRay logged that command line with doubled backslashes (C:\\Users\\Public\\Downloads\\PoItWjNFjv.vbs), so the path in files_written is not a substring of the process name. When a rule should match and does not, check the raw strings first. Here ancestor_names gives a check that does not depend on how paths are written: the task runs wscript.exe with the dropped script, the script starts PowerShell, and taskeng.exe is among that PowerShell's ancestors. A PowerShell process with for any a in p.ancestor_names : (a icontains "taskeng.exe") catches it.

Case 5: .NET binaries reading browser credentials

Loaders often inject the final payload into a signed .NET Framework binary such as MSBuild.exe, RegAsm.exe, RegSvcs.exe, InstallUtil.exe or aspnet_compiler.exe. On their own, these processes are normal on a developer machine. Started by PowerShell or a scripting host, and writing temporary copies of browser credential databases, they are not.
import "vt"

rule dotnet_lolbin_touches_browser_logins {
  condition:
    for any p in vt.behaviour.processes_list : (
      (
        p.name icontains "aspnet_compiler.exe" or
        p.name icontains "regasm.exe" or
        p.name icontains "regsvcs.exe" or
        p.name icontains "msbuild.exe" or
        p.name icontains "installutil.exe" or
        p.name icontains "addinprocess"
      ) and
      for any a in p.ancestor_names : (
        a icontains "powershell" or
        a icontains "wscript" or
        a icontains "cscript" or
        a icontains "mshta"
      ) and
      for any f in p.files_written : (
        f icontains "\\User Data\\" and
        (f icontains "\\Login Data" or f icontains "\\Web Data" or f icontains "\\Cookies")
      )
    )
}
All three conditions apply to the same process: what it is, what is above it, and what it wrote. That is the difference from the flat fields. behaviour_files:"Login Data" together with behavior_processes:"MSBuild.exe" can match a file where those two things happened in unrelated processes.
The rule matches a549a6c8c01da1d03e8da663eec70997a5bd5207e665d8e23f6fa75804dafc06, a VBS file named RFQ_638O-0194RFQ26_202610070614034439RFQ.vbs (14/76). In the CAPE report, a hidden PowerShell that rebuilds its script from environment variables is started through WMI (its parent is wmiprvse.exe), and that PowerShell starts MSBuild.exe, which writes ...\Google\Chrome\User Data\Default\Login Data.tmp and the matching -wal and -shm files. It also matches the Excel file from case 1, d30a0a6c09f664446943d82982f0630e85c447cbe84be8a74b6db6d66effbe80, where the last process in the chain, aspnet_compiler.exe, writes the same files. In that file, the ancestor_names of aspnet_compiler.exe go all the way back to the exploit: the PowerShell running XBRYINEZ.ps1, WScript.exe, the obfuscated PowerShell, cmd.exe and mshta.exe -Embedding.
Checking the ancestors instead of only the parent matters in 2d199776cc50b9ad548132105e95af86b29ec8ebca03895a7ddecde58587306b, the Request Quotation.xls from case 1. There, the process that writes the Chrome and Edge Login Data and Web Data files is a second aspnet_compiler.exe, started with /stext by the first one. Its parent is aspnet_compiler.exe, so a rule that only looks at parent_name misses it, while the PowerShell and WScript.exe above it are its second and third ancestors. On the 420 malicious and 324 clean files we used to measure ancestor_names, this version matched 5 malicious files that the parent-only version missed, and none of the clean ones.

Testing the rules

Everything in this post was tested two ways. First, against the hashes above and against a set of clean files, using the Livehunt rule tester. The tester rebuilds the vt module for the hash you give it, so it works for files analyzed before these fields existed. Second, in a live Livehunt ruleset with all the rules. Within a few minutes of enabling it we were already getting notifications for mshta_spawns_shell, dcom_mshta_spawns_shell, task_scheduler_runs_dropped_file and others. The ancestor_names versions of the rules were also run with YARA-X 1.22.0 against the same hashes and 324 clean files.

Wrapping up

A command line tells you what ran. Its parent and ancestors tell you how it got there, and the files it wrote tell you what it left behind. Rules that join the two are harder for a sample to sidestep, because the operator would need to change the chain, not just the strings. Open the vt module view of any file you are investigating, find the processes_list entry that bothers you, and write the rule from there.
Happy hunting!

0 comments:

Post a Comment