Sequences
In a nutshell, sequence rules allow to model behaviors that unfold over time by tracking an ordered chain of events. Instead of matching a single event in isolation, sequences let you express causality: this happened, and then shortly after, that happened.
A sequence rule always starts with the sequence keyword and is composed of two or more expressions separated by vertical bars (|). Each expression represents a step in the behavioral chain. A match occurs only if all expressions evaluate to true and they do so in the declared order occurring within the allowed time window (if specified).
Let’s revisit a real-world example:
condition: >
sequence
maxspan 2m
by ps.uuid
|open_process and
ps.access.mask.names in ('ALL_ACCESS', 'CREATE_PROCESS', 'VM_READ') and
evt.arg[exe] imatches '?:\\Windows\\System32\\lsass.exe' and
ps.exe not imatches
(
'?:\\Windows\\System32\\svchost.exe',
'?:\\ProgramData\\Microsoft\\Windows Defender\\*\\MsMpEng.exe'
)
|
|create_new_file and file.extension iin ('.dmp', '.mdmp', '.dump')|
This rule detects a classic LSASS memory dump pattern:
- A process opens a handle to
lsass.exewith suspicious access rights - The same process writes a minidump file shortly after
This is a strong behavioral signal because neither event alone is necessarily malicious, but their combination in sequence is.
Execution model
A sequence behaves like a state machine. Each incoming event is evaluated against the current stage(s) of active sequences. When the first expression matches, a new sequence instance is created. That instance waits for the next expression to match. If all expressions match in order the sequence fire. If constraints like time or process termination are violated the sequence is discarded. Multiple sequence instances can exist concurrently for different processes or entities.
Controlling sequence behavior
maxspan
maxspan defines the maximum time allowed between the first and last expression in the sequence. Supported units for time window duration are exprssed as 2s, 2m, or 2h for two minutes, two hours, and two days respectively. The time window dictates how long each expression in the sequence is expecting to wait for events that could result in expression evaluating to true. For example, by examining the above snippet, the sequence starts by detecting process handle acquisition on the lsass process. Since this is the first expression in the sequence, the time window constraint doesn't kick in yet. After the first expression evaluates to true, the next one, expecting to detect creation of the minidump file, will evaluate only if the CreateFile event arrives within the 2 minutes time frame. Otherwise, the deadline is reached and the entire sequence is discarded.
?> Without maxspan sequences can live indefinitely, which is usually undesirable in high-throughput systems.
by
by clause enables event stitching ensuring that only related events participate in the same sequence. Continuing the example from previous rule, the sequence can match only if OpenProcess and CreateFile events are generated by the same process. Specifically, events are joined by the ps.uuid field which is meant to offer a more robust version of the ps.pid field that is resistant to being repeated. A variation of the by statement allows establishing a joining criteria separately on each expression in the sequence.
sequence
maxspan 1h
|write_file and
file.extension iin executable_extensions and
ps.name iin msoffice_binaries
| by file.path
|spawn_process and ps.name iin msoffice_binaries| by ps.exe
As we can observe, the by statement is anchored to each expression but using a different join key. This rule would match only if the file being written is equal to the spawned process executable image. This effectively expresses, the file that was written must be the same file that gets executed later.
Of course, it is possible to omit both maxspan and by statements. However, such rules are rarely used to express behaviors that require relationships between events, instead, a loose temporal correlation.
by also supports event correlation by mutlitple join keys. For example, this rule matches when section unmapping and executable loading events arrive from the same process and section base address.
sequence
maxspan 40s
|unmap_view_of_section and
evt.pid != 4 and ps.sid not in ('S-1-5-18', 'S-1-5-19', 'S-1-5-20') and
file.view.size > 20000 and file.view.protection != 'READONLY'
| by ps.uuid, file.view.base
|load_executable and
module.path not imatches '?:\\Windows\\SoftwareDistribution\\*.exe'
| by ps.uuid, module.base
Aliases
Sometimes, simple equality joins with the by clause are not enough. You may need to compare values across steps, perform transformations, or match against derived data.
To address this limitation, Fibratus provides the more flexible as statement. It allows you to assign aliases to sequence expressions, which can later be referenced using bound fields. A bound field is simply a regular filter field prefixed with an alias, enabling access to values from previously matched events.
Let’s look at an example:
sequence
maxspan 5m
|create_file and
file.name imatches '?:\\Windows\\System32\\*.dll'
| as e1
|modify_registry and
registry.path ~= 'HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Lsa\\Notification Packages' and
get_reg_value(registry.path) iin (base($e1.file.path, false))
|
The first expression in the sequence detects the creation of a DLL file in the system directory. When this expression evaluates to true, the matching event is stored and becomes accessible through the e1 alias.
The second expression detects modifications to a specific registry value. If it matches, the rule retrieves the registry data using the get_reg_value function. In this case, the value is a MULTI_SZ entry containing a list of strings.
This list is then compared against the file path captured by the first expression. The $e1.file.path bound field is used to reference the file path from the previously matched event, enabling correlation across sequence steps.