Every defender's runbook has Event ID 1102 in it. "Security log cleared" is one of the highest-fidelity host signals on Windows: anyone calling wevtutil cl Security, Clear-EventLog, or the underlying EvtClearLog API generates a 1102 in the same log they just emptied, with the user and process responsible. SIEM rules for it ship in every detection pack on Earth.
That covers wholesale clearing. It does not cover selective deletion of individual records inside an EVTX file, which is a different technique with a different artifact and a different toolset behind it. I spent the weekend adding a subcommand to Chainsaw to surface it (PR #228, merged in v2.15.0). This post walks through the gap, who exploits it, and what the new module looks at.
The standard detection model
When the Windows Event Log service handles a log clear, it writes a final record before the log is emptied. That record is Event ID 1102 in Security.evtx and Event ID 104 in System.evtx and Application.evtx. Both name the channel cleared and the SubjectUserSid that did the clearing. They're loud, deliberate, and easy to detect:
title: Windows Audit Log Cleared
id: a1bb05c4-a8d6-4b73-93c1-eda0a3d3a234
status: stable
logsource:
product: windows
service: security
detection:
selection:
EventID: 1102
condition: selection
level: highThe reliability of this detection is precisely what makes it the obvious thing to bypass.
What 1102 doesn't catch
The Event Log service writes the record because the service handled the clear. If you don't go through the service, you don't get the record. EVTX is a documented binary format: a file header, a sequence of 64 KB chunks, each chunk holding up to 256 records identified by a monotonically increasing EventRecordID. Each chunk has its own CRC32; the file header has its own CRC32. With a parser that understands the format, you can:
- Locate the records you want gone by
EventRecordID, by EventID, by timestamp range, or by content match. - Remove or null those records from the chunk's record array.
- Recompute the chunk's CRC32 and update the chunk header.
- Recompute the file header's CRC32.
- Write the file back to disk.
The Event Log service is never invoked. No 1102 fires. The file passes structural validation, it parses cleanly with wevtutil, and to a defender pulling logs off the host it looks like an unremarkable EVTX. The records you targeted are gone.
Who actually does this
The reference implementation is the eventlogedit module from DanderSpritz, the post-exploitation framework that came out of the 2017 Shadow Brokers leak of Equation Group tooling. Fox-IT published the analysis at the time: the module was specifically designed to hide post-exploitation activity in the Security log without tripping 1102, by surgically removing chosen records and patching everything around them so the file still validated.
The technique didn't stay in tier-1-actor hands for long. 3gstudent published eventlogedit-evtx-Remove.py, a Python recreation, on GitHub years ago. Various red-team toolkits ship variants of the same idea: search names like EventCleaner, Phant0m-style implementations (some kill the log service, some tamper records), and a number of bespoke C# / Rust ports. The basic capability has been broadly available for the better part of a decade.
In incident response, encountering it tends to coincide with a sophisticated actor who's already spent enough time on the host to care about cleanup. The technique itself is mechanical; the discipline to use it (knowing which records to remove without leaving correlated artifacts elsewhere) is what differentiates skill levels.
The fingerprint that's hard to hide
Two artifacts the tools above struggle to remove cleanly. Both are purely static-analysis observables on the EVTX file itself, no endpoint sensor required:
RecordID gaps within a single file. Per-channel EventRecordID is strictly monotonic. The Event Log service assigns the next sequential ID to every event it writes. Surgical deletion produces a hole: records 92841, 92842, 92847, 92848. The five IDs in the middle were assigned by the service when the events fired, then removed from disk after the fact. RecordID gaps at log-rotation boundaries are normal and expected, but rotation crosses files; gaps inside a single EVTX file aren't supposed to exist.
Unexplained quiet windows on chatty channels. A Windows DC's Security log produces hundreds of events a minute under normal load. A workstation's PowerShell Operational log might be sparse most of the day and active during a maintenance window. Either way, channel volume has a baseline rhythm. Deleting a block of records that fell inside a busy window leaves a flat patch in what should be a noisy timeline. The tampering tool can patch RecordIDs and CRCs; it can't fabricate the events that should have happened in the silence.
These are absence-based observations. You're not looking for an event that's there, you're looking for an event that should be there and isn't.
Sigma matches on events that are present. Both detections here reason about events that are absent: the gap between consecutive RecordIDs, the gap between consecutive timestamps. Sigma's grammar has no notion of stateful comparison across consecutive records, and no way to express "I expected an event between these two and didn't get one". This is structurally outside what Sigma can do. So: a module, not a rule.
What analyse gaps does
The PR adds an analyse gaps subcommand. Per file, it:
- Parses every record into a flat
Vec<(channel, record_id, timestamp_seconds)>. - Sorts by channel, then by RecordID.
- Sweeps consecutive pairs. Emits a
Gapwheneverrecord_id[i+1] != record_id[i] + 1, or the timestamp delta exceeds the configured threshold.
Both detectors run by default. Either can be turned off independently. The threshold for time gaps is configurable; the default is 30 minutes, which is conservative for a busy Security log and probably loose for something like the Application log on a kiosk.
# Default sweep over a directory of EVTX files
chainsaw analyse gaps ./Logs/
# Disable just the time-gap detector
chainsaw analyse gaps --no-time-gaps ./Logs/
# Tighter time-gap threshold for chatty channels
chainsaw analyse gaps --min-time-gap-minutes 5 ./Logs/
# JSON output for downstream pipelines
chainsaw analyse gaps --json -o gaps.json ./Logs/
# Datetime filtering, mirrors hunt
chainsaw analyse gaps --from 2026-04-12T00:00:00 --to 2026-04-13T00:00:00 ./Logs/Default output is human-readable text. From the PR's example run on a real fixture:
=== ./Logs/Security.evtx ===
[+] Channels seen:
- Security: 184302 records, RecordID 4521..188822, 2026-04-01T00:00:11Z -> 2026-04-25T18:42:09Z
[!] 2 RecordID gap(s) detected (possible selective record deletion):
- Security: RecordID 92841 -> 92847 (5 missing) between 2026-04-12T03:14:08Z and 2026-04-12T03:14:09Z
- Security: RecordID 138210 -> 138215 (4 missing) between 2026-04-19T22:01:33Z and 2026-04-19T22:01:34Z
[+] No suspicious time gaps detectedJSON is a flat array of gap rows, one per gap, with the file path injected via a wrapper struct. That shape is friendly to anything that consumes flat tables (jq, jsonl tooling, downstream CSV converters, ad-hoc Python):
[
{
"path": "./Logs/Security.evtx",
"kind": "record_id",
"channel": "Security",
"from": 92841,
"until": 92847,
"start": 1744431248,
"stop": 1744431249
},
{
"path": "./Logs/Security.evtx",
"kind": "timestamp",
"channel": "Microsoft-Windows-PowerShell/Operational",
"from": 0,
"until": 0,
"start": 1744701600,
"stop": 1744705200
}
]The standard Chainsaw analyse-family options carry over: -o, -q, --skip-errors, plus the datetime filtering quartet (--from, --to, --local, --timezone) lifted from hunt so a sweep can be scoped the same way you'd scope a Sigma run. Mirroring hunt's datetime options was Alex Kornitzer's call on review, thanks Alex.
Caveats
A handful of legitimate scenarios produce gaps too. The module surfaces them; deciding which are malicious is the analyst's call.
- Log rotation. When the active EVTX fills, the service rolls to a new file and continues numbering. RecordID continuity across files isn't this module's concern; gaps inside a single file are.
- Sparse channels. A 30-minute quiet window on PowerShell Operational on a workstation that rarely runs scripts isn't tampering. Tune
--min-time-gap-minutesper channel where it matters, or disable the time detector entirely with--no-time-gapsand rely on RecordID gaps alone. - Service disruption. Event Log service crashes, host reboots, and bugcheck-induced log breaks can produce time gaps with no malicious cause. Correlate with
System.evtxfor the boot / service start events around the window. - Sufficiently careful tampering. A tool that also renumbers all subsequent RecordIDs after a deletion (and recomputes every downstream chunk CRC) closes the RecordID-gap hole. The time-gap fingerprint is harder to fabricate because shifting timestamps would corrupt timeline correlation against every other artifact on the host. Anyone good enough to handle the renumber is usually good enough not to leave a flat patch in the timeline either, but the two evasions stack on top of each other.
The detector is an addition to a triage flow, not a replacement for any of the standard moves. Run it alongside chainsaw hunt on the same EVTX set; the cost is minimal and it surfaces a class of behavior that hunt by definition cannot.
Tests
Five unit tests in src/analyse/gaps.rs cover the gap-detection logic in isolation. Three integration tests in tests/cli.rs exercise the CLI text and JSON paths against the existing tests/evtx/security_sample.evtx fixture. Verified zero false positives on the clean fixture and a threshold-zero case to force a positive.
The shape of the final code (single flat Gap struct with a kind enum, intermediate Vec instead of a BTreeMap, the datetime options matching hunt, flattened JSON wrapper) reflects the maintainer's structural review on #228.
If there's a deletion technique not covered by the two detectors above that you've seen in the wild, drop me a note. The module is a starting point; absence-based detection has plenty of room to grow.