Elastic Certified SIEM Analyst study materials
Elastic Certified SIEM Analyst study resources

EQL sequence returns zero rows for known lateral movement

Elastic Certified SIEM Analyst · Put your knowledge to work in context.

All case studies

EQL sequence returns zero rows for known lateral movement

You are hunting suspected lateral movement after a helpdesk ticket about a locked account. Sources are logs-windows.security-* (4624, 4625, 4771) and logs-endpoint.events.process-*, with 30 days hot and 6 months warm. There are 3,000 Windows hosts. Your query is an EQL sequence joining on source.ip with maxspan=10m: a stage matching event.category == 'authentication' and event.outcome == 'failure' followed by a stage matching event.category == 'authentication' and event.outcome == 'success'. It returns zero rows over 30 days, yet you can manually find 4625-then-4624 pairs from the same workstation in Discover. A colleague's looser version of the query returns tens of thousands of rows, almost all DC-to-DC Kerberos traffic.

1. Diagnose the zero-row result. What is the most likely cause and how do you fix it without rewriting the hunt?

Read the worked answer

Zero rows over 30 days means the join key is empty, not that the sequence logic is wrong. In EQL, sequence by X correlates only events where X exists; events with a missing or null join key are never buffered, so the pair can never form.

On the Windows integration, source.ip is populated for interactive logons (4624/4625) but Kerberos events such as 4771/4768 frequently carry the address only in winlog.event_data.IpAddress, leaving ECS source.ip empty. Any stage that matches those events is uncorrelatable by source.ip.

Fix at ingest, not in the query: add an ingest set processor that copies winlog.event_data.IpAddress into source.ip when source.ip is missing, with source.ip mapped as ip. Backfill is not possible without reindexing, so scope the hunt on @timestamp to after the pipeline change and say so in the write-up.

Two other silent killers to rule out before blaming the join:

  • event.outcome must literally be failure on 4625. If the pipeline version predates the ECS mapping, the field is absent and the first stage never matches.
  • The second stage may be categorised iam rather than authentication for some Kerberos codes, so the stages never meet.

Verify with a plain search (event.code: 4625 and inspect source.ip, event.outcome, event.category) before touching the sequence.

2. Your colleague's version is too noisy. Walk through the filters you would add, and name one filter you would refuse to add.

Read the worked answer

Tighten in this order, re-running counts after each step so you can see which filter did the work:

  1. Exclude machine accounts: user.name ending in $, plus SYSTEM and ANONYMOUS LOGON.
  2. Exclude domain controllers from the second stage via a host.name value list, or restrict the query to the workstation data streams.
  3. Exclude loopback and self traffic: source.ip != 127.0.0.1 and source.ip != ::1.
  4. Add an explicit source.ip != null so the join key requirement is visible in the query text rather than implicit.

Refuse to add: by source.ip, user.name. It looks tidy but breaks the main attack pattern. Password spraying uses many usernames and then succeeds with one; if the join key includes the username, the failures and the success never correlate. Join on source.ip alone, and write a second sequence joining on user.name to catch the spray-then-success shape from a distributed source set.

A second refusal: do not require the failure stage to be the same event.code as the success stage. Attackers routinely generate 4771 failures and then a 4624 success.

Validate against a day you can manually confirm had a helpdesk lockout, so you know the pair shape exists in your data before trusting a zero result.

3. Before running this over 30 days on 3,000 hosts, what do you check for cost and cardinality?

Read the worked answer

Cost: a 30-day EQL sequence across Security and endpoint streams on 3,000 hosts is large and stateful. Narrow the candidate set first with a plain search or ES|QL over 24 hours (event.category: authentication and source.ip: * and event.outcome: failure), take the top source.ip values, then run the sequence scoped to those. Use Timeline for iterative pivots and reserve EQL for the sequence itself.

Cardinality: sequence state is held per join-key value for the duration of maxspan. Joining on a high-cardinality key (user.name across 40k accounts, or process.entity_id) can consume significant coordinating-node memory and hit circuit breakers. by source.ip is far lower cardinality than by user.name. If the query fails with a memory error, shorten maxspan and the time range before asking for cluster changes.

Tiering: data older than 30 days sits on warm nodes with fewer resources, and warm-tier queries are slower and cheaper to run badly. Start with 7 days, prove the query, then extend. If the hunt must span frozen or searchable-snapshot data, expect latency that makes interactive hunting impractical; run those as scheduled searches instead.

Finally, record the query, the time range and the data-pipeline version in the case. A hunt whose result depends on a pipeline change made yesterday is not reproducible next month without that context.