Security Operations

What Is Event Correlation in SIEM?

HuntRule Team · · 10 min read

Two heavy steel cables running out of darkness and spliced into one thicker line at a single bright crimped junction
On this page

Windows Security event 4625 on its own is a typo. Thirty of them against thirty different account names from one source address in fifteen minutes, followed by a 4624 from that same address, is a password spray that worked. Nothing in the first event carries that conclusion. The conclusion lives in the relationship between events.

That relationship is what event correlation means in a SIEM. Every correlation is three things: a set of base events, a key those events share, and a time window. The join is where it works and the join is where it fails.

The four types, precisely

Sigma expresses correlation in a correlation section instead of a detection section, in a rule that references other rules by id or by name. Spec version 2.1.0, dated 2025-08-02, is inconsistent with itself about how many types exist. Its history section records value_sum, value_avg and value_percentile as added in that release, and the body gives each of the three its own section and example, but the normative list of allowed values for type still names only event_count, value_count, temporal and temporal_ordered. Those four are the ones you will actually write, and they are the four below.

Counting over a window (event_count)

Counts events matching the referenced rule inside timespan, per group-by key. The condition is a map of criteria drawn from gt, gte, lt, lte, eq and neq.

title: Many failed logins
id: 0e95725d-7320-415d-80f7-004da920fc11
correlation:
    type: event_count
    rules:
        - 5638f7c0-ac70-491d-8465-2a65075e0d86
    group-by:
        - ComputerName
    timespan: 1h
    condition:
        gte: 100

A hundred 4625s against one host in an hour. Note the key. Group by ComputerName and you find hosts under attack, group by TargetUserName and you find accounts under attack. Same events, different detection.

Unique-value counting (value_count)

Counts distinct values of one field, named in condition.field, per group key. This is the type that separates a spray from a guess.

title: Failed login
id: 0e95725d-7320-415d-80f7-004da920fc12
correlation:
    type: value_count
    rules:
        - 5638f7c0-ac70-491d-8465-2a65075e0d86
    group-by:
        - ComputerName
        - WorkstationName
    timespan: 1d
    condition:
        field: User
        gte: 100

One source, a hundred distinct usernames, one day. An event_count of a hundred failures also fires on a service account with a stale password retrying in a loop. The distinct count does not.

Temporal sequence (temporal)

All referenced rules must match inside timespan, in any order, with the same values for every group-by field. There is no condition. Recon behaviour is the standard case, because each command on its own is administrative.

correlation:
    type: temporal
    rules:
        - recon_cmd_a
        - recon_cmd_b
        - recon_cmd_c
    group-by:
        - ComputerName
        - User
    timespan: 5m

In practice those three are Sysmon Event ID 1 rules for whoami.exe (T1033), net.exe with group "Domain Admins" /domain (T1087.002) and nltest.exe with /dclist: (T1018). Any one of them is a Tuesday. All three by the same account on the same host inside five minutes is not.

Temporal ordered sequence (temporal_ordered)

Identical to temporal, plus the events must appear in the order the rules are listed under rules.

correlation:
    type: temporal_ordered
    rules:
        - many_failed_logins
        - successful_login
    group-by:
        - User
    timespan: 1h

Order is the whole point. Failures then a success is a brute force that landed. A success then failures is somebody who logged in and later fat-fingered a runas. Note also what the spec says about that inner rule: even if many_failed_logins groups by ComputerName, the correlation uses only its own group-by, which is User.

The join key decides everything

A correlation is only as good as the identifier the events genuinely share. Ranked by how much abuse they survive:

  1. ProcessGuid. Sysmon stamps a globally unique GUID on Event ID 1, and the same GUID appears on Event ID 3 network connections and Event ID 11 file creates from that process. It is not reused. Joining on PID instead will eventually join two unrelated processes, because Windows recycles PIDs.

  2. Logon session. Event 4624 emits TargetLogonId, and 4688 emits SubjectLogonId for processes started in that session. Exact, but host-local. LogonId is unique per host per boot, so it must be paired with the hostname.

  3. Account name. Survives crossing log sources, but CORP\jsmith, jsmith@corp.local and jsmith are three different strings to a group-by.

  4. Source address. NAT collapses a whole office behind one value, and a VPN concentrator will happily correlate two unrelated users.

When the same real-world value has different field names on either side, Sigma has aliases, which map an alias to a source field per referenced rule name for use in group-by:

correlation:
    type: temporal
    rules:
        - internal_error
        - new_network_connection
    group-by:
        - internal_ip
        - remote_ip
    timespan: 10s
    aliases:
        internal_ip:
            internal_error: destination.ip
            new_network_connection: source.ip
        remote_ip:
            internal_error: source.ip
            new_network_connection: destination.ip

The name attribute is optional on a normal Sigma rule. It is mandatory on any rule referenced from aliases.

Clock skew and late arrival

Every temporal correlation orders events by a timestamp, and which timestamp is a decision most teams never make deliberately. If ordering runs on the event's own generated time, a domain controller two seconds ahead of a member server can put the successful logon before the failures and silently break temporal_ordered. If it runs on ingest time, agent batching decides the order instead.

Late arrival is the same problem from the other end. Shippers buffer and retry. An endpoint that was asleep sends its 4624 with a correct original timestamp long after the search covering that period has run, so a correlation running every five minutes over the last five minutes never sees it. The usual fix is a lookbehind delay plus an overlapping window, at the cost of duplicate alerts you then suppress.

Window length is a base-rate decision

Too short and slow attacks walk through. A spray at one attempt per account per hour never reaches fifteen distinct accounts in a fifteen-minute window. Too long and you are not detecting a relationship any more, you are detecting coincidence. Failed and successful logons for the same user are both common, and over a 24-hour window on a busy source address they co-occur constantly with no attack involved. Pick the window from the tempo of the behaviour, then measure how many groups the query returns on a quiet week before you attach an alert to it.

A worked rule

Password spray from one source, followed by a successful interactive or network logon from that same source, within thirty minutes.

title: Password spray from a single source followed by a successful logon
id: 4a0f9c1e-2b7d-4d21-9c3e-8f1a6b2d4e70
status: experimental
description: Failed logons against many distinct accounts from one source address, followed by a successful logon from that address
author: HuntRule
date: 2026-07-31
correlation:
    type: temporal_ordered
    rules:
        - spray_many_accounts
        - successful_logon
    group-by:
        - IpAddress
    timespan: 30m
falsepositives:
    - Vulnerability scanners and pentest tooling authenticating from a fixed address
    - A shared NAT egress address where an expired password storm coincides with normal logons
level: high
---
title: Failed logons against many distinct accounts from one source
id: 9d2c7b41-5f88-4a0e-b6c3-1e7d5a904f22
name: spray_many_accounts
correlation:
    type: value_count
    rules:
        - failed_logon
    group-by:
        - IpAddress
    timespan: 15m
    condition:
        field: TargetUserName
        gte: 15
---
title: Failed logon
id: c1f6a83b-7e40-4b95-8d2a-0c5b9e3f6714
name: failed_logon
logsource:
    product: windows
    service: security
detection:
    selection:
        EventID: 4625
    filter_local:
        IpAddress:
            - '-'
            - '127.0.0.1'
            - '::1'
    condition: selection and not filter_local
---
title: Successful logon
id: 7b3e5d92-4c16-42af-9e80-2a6f8c1b5d33
name: successful_logon
logsource:
    product: windows
    service: security
detection:
    selection:
        EventID: 4624
        LogonType:
            - 3
            - 10
    condition: selection

The exact assumptions it makes, all worth checking against your own data first:

  1. IpAddress is populated and formatted identically in 4625 and 4624. Local and cached logons write - or a loopback address, which is why the base rule filters them. Correlating on those values would join the entire estate into one group.

  2. The windows are nested. Fifteen distinct accounts must fail inside fifteen minutes, and the successful logon must land inside a thirty-minute window that starts with the spray. A spray that succeeds forty minutes later does not fire.

  3. Ordering is by whatever timestamp the backend sorts on. The 4625s usually come from a domain controller and the 4624 may come from a different host, so the two clocks must agree to well inside thirty minutes.

  4. Both streams sit in one searchable index with a comparable time field. Different retention on the two sides produces silent false negatives.

  5. Fifteen distinct accounts is a guess until you have run the value_count half alone against your own history.

Known blind spot: Kerberos. A spray against a domain controller over Kerberos produces event 4771, Kerberos pre-authentication failed, with failure code 0x18 for a bad password. It does not produce 4625. Covering it means a second base rule on 4771, whose IpAddress a DC often writes in mapped form such as ::ffff:10.0.0.5. That formatting difference is the kind of thing that quietly kills a join.

Backend support is uneven and correlation is not free

Sigma correlations are newer than Sigma detections and backend coverage reflects that. The SigmaHQ documentation currently lists support in Splunk SPL, Elasticsearch ES|QL, Grafana Loki, SQL and OpenSearch. The specification requires a backend to raise an error when a mandatory feature is unsupported, and to warn on softer gaps. It names two worth knowing. A backend that recognises temporal proximity but not ordering produces false positives from differently ordered events. A backend that only evaluates within fixed boundaries, where a one-hour timespan means the clock hour rather than any rolling hour, produces false negatives when events straddle the boundary. Convert the rule and read the warnings before trusting it.

The runtime cost is real too. A stateless detection evaluates one event and forgets it. A correlation holds state for every distinct group key seen in the window, and temporal types keep partial matches alive waiting for the rest of the sequence. Memory and CPU scale with the cardinality of your group-by, which is why IpAddress behaves very differently from ComputerName on the same estate. It is also why correlation runs on a schedule over a lookback window rather than in a streaming path.

When to correlate

Correlate when a single event genuinely cannot carry the decision, not because correlation sounds like the more advanced technique. If one 4688 command line is enough to page someone, write a plain Sigma rule and move on. Correlation earns its cost in two cases: the individual events are too common to alert on alone, or the ordering or count is the malicious part. Everything else is a stateless match wearing a costume, and that decision is covered further in what a detection rule is.

ATT&CK techniques referenced

Technique

ID

Where it appears

System Owner/User Discovery

T1033

whoami.exe, temporal example

Remote System Discovery

T1018

nltest /dclist:, temporal example

Account Discovery: Domain Account

T1087.002

net group "Domain Admins" /domain

Brute Force: Password Spraying

T1110.003

The worked rule

Valid Accounts

T1078

The successful 4624

Summary

Correlation is a join with a clock on it. Sigma gives four practical types: event_count for volume, value_count for distinct values, temporal for co-occurrence and temporal_ordered for sequence. The rule holds together only if the group-by key really identifies the same entity on both sides, if the clocks agree to well inside the window, and if late events arrive before the search runs. Backend support is partial and stateful correlation costs more than a stateless match, so spend it where a single event cannot carry the decision and nowhere else. Base rules to correlate against are in the Windows rules section of the catalog.

Collect these before writing any of the above:

4624  An account was successfully logged on          (TargetUserName, TargetLogonId, LogonType, IpAddress)
4625  An account failed to log on                    (TargetUserName, LogonType, Status, SubStatus, IpAddress)
4634  An account was logged off                      (TargetUserName, TargetLogonId, LogonType)
4688  A new process has been created                 (NewProcessName, CommandLine, SubjectLogonId)
4771  Kerberos pre-authentication failed             (TargetUserName, Status, IpAddress)
Sysmon 1   Process creation                          (Image, CommandLine, ProcessGuid, ParentProcessGuid, User)
Sysmon 3   Network connection detected               (Image, ProcessGuid, DestinationIp, DestinationPort)
Sysmon 11  File created                              (Image, ProcessGuid, TargetFilename)

Related articles