Триаж рабочей станции: журнал Sysmon, скрипт на python-evtx — всё как в сотне туториалов:

from Evtx.Evtx import Evtx

with Evtx("Sysmon.evtx") as log:
    for record in log.records():
        ...

Скрипт отработал. Без исключений, без предупреждений, с кодом возврата 0. На выходе — 94 записи.

Проблема в том, что в файле их 2309.

А в потерянных 96% лежала вся атака: создание админской учётки, дамп паролей, архивирование каталогов перед выгрузкой и запуск шифровальщика. Целиком. От первого до последнего события.

Сразу оговорка, чтобы не тратить ваше время зря: речь про дефолтное поведение python-evtx. Rust-парсер evtx, EvtxECmd и wevtutil этот же файл читают целиком — ниже я показываю, как в этом убедиться. Так что вопрос не «сломана ли библиотека», а «что именно из этого делает ваш пайплайн».

TL;DR

  • В заголовке .evtx есть поле chunk_count. На журнале, снятом с работающей машины, оно почти всегда занижено — это не повреждение, а штатное состояние.

  • python-evtx по умолчанию верит этому полю и останавливает обход на нём. Ошибки нет, кода возврата нет, есть число записей, которое ничего не значит.

  • Проверка «а нет ли дыр в EventRecordID» этот случай не ловит по построению — она подтвердит целостность данных, которых у вас нет.

  • Что сделать прямо сейчас: прогнать любой .evtx с живой машины через свой пайплайн и сравнить с wevtutil gli /lf:true. Пятнадцать строк для той же проверки без Windows — в разделе «Как это проверять».

Стенд. Образ: Windows 7 Pro SP1 (6.1.7601), логи сняты с работающей машины скриптом live-response-сбора. python-evtx 0.8.1, evtx (rust-биндинг) 0.12.1, Chainsaw 2.16.2. Даты в выводах инструментов изменены, EventRecordID и всё остальное — подлинные.

Два байта, которым все верят

Раскладка .evtx: заголовок 4096 байт, 33 слота по 64 КБ, из них 30 chunk'ов и 3 слота с посторонними данными; chunk_count = 1 указывает только на первый
Раскладка .evtx: заголовок 4096 байт, 33 слота по 64 КБ, из них 30 chunk'ов и 3 слота с посторонними данными; chunk_count = 1 указывает только на первый

Формат EVTX устроен просто: заголовок файла на 4096 байт, дальше — chunk'и по 65 536 байт (0x10000), каждый со своим заголовком ElfChnk\x00. Записи живут внутри chunk'ов.

В заголовке файла по смещению 0x2A лежит chunk_count — двухбайтовое поле: сколько в файле chunk'ов.

Вот заголовок того самого Sysmon-журнала (2 166 784 байта — 33 слота под chunk'и):

00000000  45 6c 66 46 69 6c 65 00   ElfFile.    магия
00000008  00 00 00 00 00 00 00 00               oldest_chunk
00000010  00 00 00 00 00 00 00 00               current_chunk_num
00000018  b9 2b 00 00 00 00 00 00               next_record_num = 11193
00000020  80 00 00 00 01 00 03 00               hdr_size=128, minor=1, major=3
00000028  00 10 01 00                           hdr_chunk_size=0x1000, chunk_count = 1  <--
...
00000078  01 00 00 00                           flags = 0x1 (DIRTY)                     <--

chunk_count = 1. Физически chunk'ов — 30.

Это не повреждение. Это нормальное состояние живого журнала. Флаг 0x1 (dirty) означает ровно то, что написано в спецификации libevtx: журнал открыт и изменялся, и не все изменения отражены в заголовке.

Насколько «не все» — видно по второму полю. next_record_num в этом заголовке — 11193, и ровно такой EventRecordID у первой записи первого chunk'а. То есть заголовок — снимок состояния на момент, когда система собиралась писать запись №11193 в первый (и тогда единственный) chunk. После этого Windows дописала ещё 29 chunk'ов и 2215 записей, ни разу не тронув заголовок файла. Не «обновляла редко» — не обновляла вообще, все 45 минут жизни журнала.

Отсюда важное следствие, которое определяет, касается ли вас всё дальнейшее: журнал, экспортированный через wevtutil epl, или снятый с корректно выключённой машины, заголовку не противоречит — там chunk_count верный и проблемы нет. Расхождение живёт на копиях, снятых с работающей системы: триажные сборы, VSS-снимки, файлы, вытащенные из смонтированного образа живой машины. То есть на самом массовом способе получить логи.

Насколько это массово

В образе 137 журналов с валидным заголовком. Разложим их честно, потому что цифра «26 из 137 — dirty» сама по себе ничего не значит:

файлов

всего .evtx

137

из них dirty (flags & 1)

26

из них многочанковых (реально больше одного chunk'а)

6

многочанковых и dirty

4

многочанковых, dirty и с заниженным chunk_count

3

Ключевой момент, который легко упустить: у 22 из 26 dirty-журналов физически один chunk. Занижать там нечего — chunk_count = 1 и есть правда, баг не проявляется. Проблема живёт только на журналах больше одного chunk'а, а это ровно те, где накопилось много событий, то есть самые интересные для расследования.

Все шесть, целиком:

Журнал

chunk_count

Chunk'ов реально

Битых слотов

dirty

Записей заявлено

Sysmon

1

30

3

да

2309

CAPI2

14

14

2

нет

513

GroupPolicy

5

5

0

да

670

TS-RCM

1

4

0

да

406

Firewall

2

3

0

да

165

WSAT

2

2

0

нет

126

Полные имена каналов: Microsoft-Windows-Sysmon/Operational, .../CAPI2/Operational, .../GroupPolicy/Operational, .../TerminalServices-RemoteConnectionManager/Operational, .../Windows Firewall With Advanced Security/Firewall, .../WindowsSystemAssessmentTool/Operational. Дальше по тексту — короткие имена из таблицы.

Обратите внимание на GroupPolicy: журнал dirty, но chunk_count при этом верный. Так что «dirty ⇒ заголовок врёт» — неправда, зависимость односторонняя: чистый журнал заголовку не противоречит, грязный — может. Выборка тут крошечная, из одного образа, и я не берусь по ней утверждать «в X% случаев». Утверждаю другое: это не экзотика, и вероятность встретить такой файл в реальном триаже далека от нуля.

Столбец «битых слотов» пока просто запомните — к нему вернёмся в разделе «Мусор в хвосте», там самое неочевидное.

Где именно теряются записи

Теперь смотрим в python-evtx 0.8.1, Evtx/Evtx.py:

def chunks(self, include_inactive=False):
    if include_inactive:
        chunk_count = sys.maxsize
    else:
        chunk_count = self.chunk_count()      # <--- из заголовка файла

    i = 0
    ofs = self._offset + self.header_chunk_size()
    while ofs + 0x10000 <= len(self._buf) and i < chunk_count:
        yield ChunkHeader(self._buf, ofs)
        ofs += 0x10000
        i += 1

Вот и всё. i < chunk_count — обход останавливается на значении из заголовка файла. records() идёт через chunks() с дефолтным include_inactive=False.

Заголовок сказал «один chunk» — библиотека прочитала один chunk, отдала 94 записи и штатно завершилась. Она сделала ровно то, что от неё просили. Ошибки не было.

Замеры по всем шести многочанковым журналам:

Журнал

records() по умолчанию

include_inactive=True

Заявлено заголовками

Потеряно

Sysmon

94

2309

2309

96%

TS-RCM

125

406

406

69%

Firewall

150

165

165

9%

CAPI2

513

513

513

GroupPolicy

670

670

670

WSAT

126

126

126

Что именно оказалось в потерянных 96%

Шкала журнала на 45 минут: прочитанное окно — первые 34 секунды, всё остальное с событиями атаки парсер не увидел
Шкала журнала на 45 минут: прочитанное окно — первые 34 секунды, всё остальное с событиями атаки парсер не увидел

Это не абстрактная «часть данных». Вот атака, восстановленная по полному журналу. Время — от первой записи журнала, EventRecordID — подлинные:

EventRecordID

Δ от начала

Событие

11193

0:00

первая запись журнала

11286

+0:34

последняя запись, которую увидел python-evtx

12251

+9:39

net user /domain — разведка учёток

12257

+10:14

net user <acct> ******** /add — создание учётки

12272

+11:54

net localgroup Administrators <acct> /add

12279

+12:09

net localgroup "Remote Desktop Users" <acct> /add

12420

+12:54

AnyDesk.exe --control — удалённый доступ

12510

+15:17

сетевой сканер с рабочего стола — разведка сети

12741

+17:59

LaZagne.exe — дамп сохранённых учётных данных

12799

+18:19

WinRAR a ... -- . <веб-каталог> — сбор данных

12818

+19:16

WinRAR a ... -- . <каталог бэкапов БД>

13002

+26:44

cryptor.exe -p "<веб-каталог>" -ef

13014

+27:14

cryptor.exe -p "<каталог бэкапов БД>" -ef

13501

+45:07

последняя запись журнала

Парсер остановился на записи 11286. Первое событие атаки — 12251. Между ними 964 записи, и ни одной из них он не увидел.

В прочитанных 94 записях нет вообще ничего: 34 секунды загрузочного шума, svchost, mscorsvw, компиляция нативных образов .NET. Скрипт отработал с кодом 0, выдал аккуратный список событий — и в этом списке двойное вымогательство выглядит как тишина.

Отдельно отмечу вторую строку таблицы потерь. TS-RCM — журнал, где живёт EID 1149, самое раннее свидетельство RDP-аутентификации. Атакующий добавил свою учётку в Remote Desktop Users; потеря 69% этого журнала бьёт ровно туда.

«Так это же документированное поведение»

Да, и докстринг честный:

If include_inactive is set to true, enumerate chunks beyond those declared in the file header (and may therefore be corrupt).

Для парсера общего назначения консервативный дефолт можно защитить: не лезть за пределы того, что заявил файл, — разумная позиция. Но в форензике этот дефолт меняет полноту на безопасность, не сообщая о размене ни строчкой. Ты не получаешь ни warning'а, ни ненулевого кода возврата — ты получаешь число, которое выглядит как ответ.

И вот что превращает это из «настраиваемого поведения» в грабли: флаг есть на уровне FileHeader, но не на уровне того класса, которым все пользуются.

Метод

Сигнатура

Evtx.records()

(self)

Evtx.chunks()

(self)

FileHeader.chunks()

(self, include_inactive=False)

log.records(include_inactive=True) — это TypeError. Верхнеуровневый класс Evtx параметр не пробрасывает: его chunks() вызывает self._fh.chunks() без аргументов. Чтобы обойти дефолт, надо спуститься на уровень ниже — к FileHeader:

from Evtx.Evtx import Evtx

with Evtx("Sysmon.evtx") as log:
    for chunk in log.get_file_header().chunks(include_inactive=True):
        for record in chunk.records():
            ...

Вот так — все 2309 записей. То есть лекарство в библиотеке есть, но между туториальным log.records() и им лежит знание о том, что у файла бывает заголовок, которому нельзя верить. А это ровно то знание, ради которого вы бы и полезли искать лекарство.

Цена у флага тоже есть: sys.maxsize заставляет идти до конца буфера, включая слоты, которые chunk'ами не являются. На моём Sysmon с тремя мусорными слотами в хвосте это отработало без исключений и выдало ровно 2309 — но рассчитывать на такую вежливость я бы не стал: обвязывайте try/except вокруг внутреннего цикла.

Для сравнения — rust-парсер omerbenamram/evtx (тот, что за evtx_dump и питоновским биндингом evtx). Он заголовку файла не верит и идёт по всем слотам до конца, и на этом Sysmon версия 0.12.1 бросает RuntimeError: Failed to parse chunk header — спотыкается о те самые три мусорных слота. Чтобы получить число, ошибку надо поймать и обойти chunk'и вручную; тогда получается 2309. Разница с python-evtx не в числе, а в громкости: один молчит и отдаёт 94, другой отказывается работать, пока вы не разберётесь с тремя слотами.

Нативный wevtutil gli /lf:true независимо подтверждает: numberOfLogRecords: 2309.

Мелочь для педантов: chunk_count — двухбайтовое поле, то есть больше 65 535 chunk'ов (примерно 4 ГБ) им в принципе не описать. На таких размерах журналов «заниженный chunk_count» перестаёт быть аномалией, но и журналов таких я живьём не видел.

Повторить у себя за минуту

Всё вышеописанное проверяется без моих файлов, на любой Windows-машине. Понадобится pip install python-evtx==0.8.1 и консоль администратора. Экспортируем что-нибудь многочанковое и занизим ему chunk_count руками:

wevtutil epl "Windows PowerShell" test.evtx

Журнал нужен именно большой: если в экспорте один chunk, занижать нечего и эффекта не будет. «Windows PowerShell» на живой машине обычно подходит; проверить можно тем же скриптом из следующего раздела.

import shutil, struct
from Evtx.Evtx import Evtx

shutil.copy("test.evtx", "faked.evtx")
with open("faked.evtx", "r+b") as f:
    f.seek(0x2A); f.write(struct.pack("<H", 1))   # chunk_count -> 1
    f.seek(0x78); f.write(struct.pack("<I", 1))   # flags -> dirty, на обход не влияет

for name in ("test.evtx", "faked.evtx"):
    with Evtx(name) as log:
        print(name, sum(1 for _ in log.records()))

У меня на файле с 240 chunk'ами:

test.evtx 12307
faked.evtx 50

12 307 записей превратились в 50 — 0,4% файла, без единого предупреждения. Попутно: CRC32 в 0x7C я не пересчитывал, и python-evtx этого не заметил — контрольную сумму заголовка он по умолчанию не проверяет.

Осторожно. Если будете сравнивать парсеры: пакеты evtx (rust-биндинг) и python-evtx не уживаются в одном venv на Windows и macOS. Первый ставит каталог evtx, второй — Evtx, а файловая система регистронезависима: второй pip install затирает содержимое первого. Плюс оба кладут в bin скрипт с именем evtx_dump. Два отдельных окружения.

Почему поиск дыр в EventRecordID вас не спасёт

Логичная реакция: «ну так проверь непрерывность EventRecordID». У Chainsaw для этого есть готовая команда analyse gaps — она ищет дыры в последовательности EventRecordID и подозрительно длинные тихие окна. В справке прямо сказано, против чего она: possible selective record deletion. То есть против антифорензики, когда атакующий выборочно вырезает записи, не трогая журнал целиком (чтобы не породить шумный EID 1102).

Проблема: такая проверка работает поверх того, что парсер сумел прочитать.

Проверим не на словах. Срез, который реально прочитал python-evtx, — это заголовок файла плюс первый chunk, первые 69 632 байта. Оформим их как самостоятельный .evtx (он, кстати, полностью самосогласован: заголовок ведь и заявляет один chunk) и скормим Chainsaw:

$ chainsaw analyse gaps Sysmon-as-read-by-python-evtx.evtx
[+] Channels seen:
    - Microsoft-Windows-Sysmon/Operational: 94 records, RecordID 11193..11286,
      2025-11-03T12:15:49+00:00 -> 2025-11-03T12:16:23+00:00
[+] No RecordID gaps detected
[+] No suspicious time gaps detected

No RecordID gaps detected. Пропусков нет, всё чисто. Вы получаете подтверждение целостности данных, которых у вас нет.

Посмотрите на временной диапазон: срез покрывает первые 34 секунды журнала, который в реальности тянется 45 минут. Шифровальщик запустится через 26 минут после конца этого окна — и для такого пайплайна он не запустится вовсе, с официальным штампом «gaps not detected».

Для полноты — тот же анализ полного файла:

$ chainsaw analyse gaps --skip-errors Sysmon.evtx
[+] Channels seen:
    - Microsoft-Windows-Sysmon/Operational: 2309 records, RecordID 11193..13501,
      2025-11-03T12:15:49+00:00 -> 2025-11-03T13:00:56+00:00
[+] No RecordID gaps detected
[+] No suspicious time gaps detected

Тоже чисто — и это правильно: из файла действительно ничего не удаляли. Обрезка была на уровне чтения, а её gap-анализ не видит по построению. (Отметим корректность Chainsaw: без --skip-errors он на этом файле вообще отказывается работать, упёршись в три битых слота, — молча пропустить их он не пытается.)

Причина общая. Windows пишет записи последовательно, chunk за chunk'ом; сколько бы chunk'ов парсер ни прочитал с начала, EventRecordID в них будут сплошными. Обрезка чтения не создаёт дыр — она создаёт укороченный, но идеально непрерывный хвост.

Два случая на оси EventRecordID: выборочное удаление оставляет дыру, которую анализ находит, а обрезка чтения оставляет короткий непрерывный хвост, на котором анализ молчит
Два случая на оси EventRecordID: выборочное удаление оставляет дыру, которую анализ находит, а обрезка чтения оставляет короткий непрерывный хвост, на котором анализ молчит

Ровно одно исключение, и оно не в вашу пользу. Если журнал уже пошёл по кругу (oldest_chunk в заголовке не ноль), физически первые chunk'и — не самые старые, и чтение N первых слотов даст разрыв, который gap-анализ действительно увидит. Только python-evtx oldest_chunk не смотрит вовсе — он начинает от header_chunk_size() независимо от него, — так что вы получите записи не в том порядке и разрыв не там, где он есть на самом деле. Диагноз «кто-то вырезал записи» на таком выводе — ровно та ошибка, которой вы пытались избежать.

Поэтому:

  • Gap-анализ отвечает на вопрос «не вырезал ли кто-то записи из файла?»

  • Но есть и второй вопрос: «а прочитал ли мой парсер всё, что в файле есть?»

Это разные вопросы, и первый не покрывает второй. Проверять надо не выборку против самой себя, а выборку против независимого источника истины — того, что о содержимом файла говорят заголовки chunk'ов, минуя парсер записей.

И проверять надо свой пайплайн целиком, а не библиотеку из этой статьи. Дефолт python-evtx — конкретный пример, но вопрос «сколько записей в файле против сколько дошло до меня» осмыслен для любого парсера, включая ваш собственный.

Как это проверять

Заголовок каждого chunk'а (ElfChnk\x00) несёт две пары чисел:

Смещение

Что там

0x08 / 0x10

номера записей в chunk'е → ожидаемое количество

0x18 / 0x20

сами EventRecordIDожидаемый диапазон

В норме эти пары дублируют друг друга, так что считать их двумя независимыми доказательствами не стоит. Полезны они по-разному:

  1. Количество. Суммируем по всем chunk'ам, сравниваем с тем, что реально отдал парсер. Расходится — парсер что-то потерял.

  2. Диапазон. Из объявленного диапазона EventRecordID вычитаем фактически прочитанные ID. Остаток — конкретные пропавшие записи, поимённо.

Ключевое: обе цифры читаются из заголовков chunk'ов напрямую, отдельным проходом по файлу, а не через API парсера. Если спросить у парсера, полный ли он прочитал файл, он ответит «да» — по определению.

Отдельного инструмента для этого не нужно, пятнадцати строк хватает:

import struct, sys

def declared(path):
    with open(path, "rb") as f:
        data = f.read()
    total, lo, hi, bad = 0, None, 0, 0
    for off in range(4096, len(data) - 0xFFFF, 0x10000):
        if data[off:off + 8] != b"ElfChnk\x00":
            if data[off:off + 0x10000].strip(b"\x00"):
                bad += 1                              # слот не пустой, но и не chunk
            continue
        first, last = struct.unpack("<QQ", data[off + 0x18:off + 0x28])
        if last < first or last - first > 0x10000:
            bad += 1                                  # заголовок chunk'а повреждён
            continue
        total += last - first + 1
        lo = first if lo is None else min(lo, first)
        hi = max(hi, last)
    return total, lo, hi, bad

for path in sys.argv[1:]:
    n, lo, hi, bad = declared(path)
    note = f", нечитаемых слотов: {bad}" if bad else ""
    print(f"{path}: заголовки заявляют {n} записей, EventRecordID {lo}..{hi}{note}")
$ python check_evtx.py Sysmon.evtx
Sysmon.evtx: заголовки заявляют 2309 записей, EventRecordID 11193..13501, нечитаемых слотов: 3

Сравните это число с тем, что вернул ваш пайплайн. Всё, проверка закончена.

Обратите внимание на две строки с continue: скан не останавливается на непонятном слоте, а идёт до конца файла. Почему это принципиально — в следующем разделе; там же разберём, что делать с нечитаемых слотов: 3, потому что правильный ответ здесь — не «OK» и не «повреждено».

Мусор в хвосте: почему «OK» здесь — неправильный ответ

Вернёмся к столбцу «битых слотов». В Sysmon три хвостовых слота из 33 не начинаются с ElfChnk\x00, но и не пустые — там данные. В CAPI2 таких слотов два из 16.

В кейсе с шифровальщиком соблазн очевиден: сказать «атакующий затёр хвост журнала». Тем более что энтропия — вот она:

Sysmon, слот 30: энтропия 7,996  печатных 36%  сигнатур: нет
                 70 a0 5f 94 a0 b9 af 23 10 df 19 a8 bf 06 51 21 ...
Sysmon, слот 31: энтропия 7,996  печатных 37%  сигнатур: нет
Sysmon, слот 32: энтропия 7,995  печатных 36%  сигнатур: нет

7,996 из максимальных 8,0 — зашифрованные или сжатые данные, структуры нет никакой. Выглядит как приговор. Но посмотрим на CAPI2:

CAPI2, слот 14: энтропия 4,968  печатных 62%  vk=457, ri=364
                31 00 00 00 00 00 00 00 a8 ff ff ff 76 6b 39 00 ...
                                         ^^^^^^^^^^^ ^^^^^
                                         размер (-88) "vk"
CAPI2, слот 15: энтропия 5,055  печатных 47%  nk=23, vk=240, ri=161

76 6b — это vk, сигнатура ячейки-значения в кусте реестра Windows. Перед ней a8 ff ff ff — отрицательный размер, признак занятой ячейки. Классическая структура hive. А если вытащить UTF-16-строки, читается вот такое:

Package_19_for_KB3033929~31bf3856ad364e35~amd64~~6.1.1.1
6.1.7601.22948

Записи хранилища компонентов Windows Update. В хвосте CAPI2 лежит кусок реестра — и сам CAPI2 при этом абсолютно здоров и даже не dirty.

Значит, хвостовые слоты в принципе умеют содержать посторонний мусор. Место под слоты выделяется сильно вперёд записи, и это видно по всем шести журналам: у TS-RCM записано 4 chunk'а при 16 слотах в файле, у GroupPolicy — 5 при 17, у Sysmon — 30 при 33. Слоты, до которых служба журналирования ещё не дописала, обычно нулевые, но не обязаны ими быть: они хранят то, что лежало на этих кластерах раньше.

Теперь — почему в Sysmon это тоже не работа шифровальщика. Две причины, и обе видны только из полного журнала:

  1. Шифровальщик показал свои цели сам. В его командной строке стоял явный -p <каталог>, и каталога с журналами среди целей не было.

  2. Журнал продолжал нормально писаться ещё 18 минут после шифрования. Последний запуск шифровальщика — на отметке +27:14, последняя читаемая запись Sysmon — +45:07. Все записи в этом промежутке целы. Файл, который шифровальщик действительно затронул бы, так себя не ведёт.

Вывод: остаточные данные, а не уничтожение улик.

Но заметьте, ценой чего получен этот вывод. Обе причины извлечены из тех самых 96%, которые дефолтный парсер не прочитал. Разбирая этот образ по 94 записям, вы бы не увидели ни командной строки шифровальщика, ни того, что журнал жил после шифрования, — зато прекрасно увидели бы три слота с энтропией 7,996 в хвосте. И написали бы в отчёт уничтожение журналов, которого не было.

Обрезка чтения не просто теряет данные. Она забирает контекст, без которого оставшиеся данные интерпретируются неправильно.

И даже теперь я не могу доказать, что в этих слотах не было затёртых кем-то записей. Высокая энтропия одинаково объясняется и остатками старого архива, и целенаправленной перезаписью. Различить по одному файлу нельзя. Поэтому единственный корректный вердикт — не «OK» и не «повреждено атакующим», а «полнота непроверяема»: 30 читаемых chunk'ов заявляют 2309 записей, парсер прочитал 2309, счётчики сошлись — но что было в трёх нечитаемых слотах, не знает никто.

Отсюда же — главная ловушка для тех, кто пишет свою проверку: не останавливайте скан заголовков на первом непонятном слоте. Если прерваться, эталон полноты сам окажется обрезанным ровно там же, где споткнулся парсер записей: прочитано и заявлено занизятся согласованно и совпадут. Проверка выдаст OK именно на том классе файлов, ради которого она писалась. Ровно поэтому в скрипте выше стоят два continue, а не break.

Итого, четыре исхода — и выводы для расследования у них разные:

Что видим

Что это значит

прочитано = заявлено, заголовки все читаемы

полнота подтверждена

прочитано < заявлено

потерял парсер: обрезка чтения, наш случай

прочитано = заявлено, но в диапазоне дыры

записей нет в самом файле: ротация или выборочное удаление — территория chainsaw analyse gaps

часть заголовков chunk'ов нечитаема

эталон неполон: честный ответ не «OK», а «полнота непроверяема»

Пятый случай — прочитано больше, чем заявлено, — тоже бывает: значит, заголовки chunk'ов устарели или повреждены и эталону доверять нельзя. Встречается редко, но обрабатывать его надо явно, иначе он маскируется под норму.

Как я свёл эту проверку в одну команду

Из этой истории выросла утилита — evtxview (Python, обёртка над rust-парсером evtx, MIT):

pipx install git+https://github.com/kotru21/evtx-viewer

Ничего принципиально нового по сравнению со скриптом выше она не делает — просто прогоняет ту же проверку по всем файлам сразу, различает четыре исхода из таблицы и называет пропавшие записи поимённо. Вывод на журналах образа, дословно:

$ evtxview logs/*.evtx --verify
logs/Sysmon.evtx: chunks=30 заявлено=2309 прочитано=2309 !!! ПОЛНОТА НЕПРОВЕРЯЕМА (chunk-errors: 3) (битых заголовков chunk'ов: 3)
  счётчики читаемых заголовков сошлись, но часть заголовков chunk'ов нечитаема — сколько записей было в них, неизвестно
logs/TS-RCM.evtx: chunks=4 заявлено=406 прочитано=406 OK
...

Два числа в первой строке — разной природы и совпали случайно: chunk-errors — сколько раз споткнулся rust-парсер записей, битых заголовков — сколько слотов не прошло независимый скан заголовков. То, что оба равны трём, здесь совпадение, и именно расхождение между ними было бы интересным.

А так выглядит обрезка чтения. Чтобы не выдумывать вывод, я сломал файл честно: взял TS-RCM и занулил область записей внутри третьего chunk'а, не трогая его заголовок. Заголовок по-прежнему заявляет записи 254–381, а прочитать их уже нельзя:

$ evtxview logs/suspect.evtx --verify
logs/suspect.evtx: chunks=4 заявлено=406 прочитано=278 !!! ОБРЕЗКА ЧТЕНИЯ
  парсер вернул меньше записей, чем заявляют заголовки chunk'ов — часть файла не прочитана
  пропущено EventRecordID: 128  [254, 255, 256, 257, 258, 259, 260, 261, 262 …(+119)]  (диапазон 1..406)

128 пропавших записей названы поимённо — это уже не «что-то потерялось», а список того, что надо искать в другом источнике.

Кроме --verify там обычный триажный набор — сводка по EventID, слияние журналов в единый таймлайн, фильтры, экспорт, несколько пресетов под типовые задачи; подробности в README, здесь они к делу не относятся.

Честно о том, где это не нужно. Detection по Sigma-правилам — это Hayabusa и Chainsaw, они делают это несопоставимо лучше. Нормализованный парсинг с картами полей под Timeline Explorer — EvtxECmd. evtxview с ними не конкурирует: это маленький CLI для быстрого триажа с одной функцией, которой я не нашёл у остальных, — проверкой того, что чтение было полным, с различением «потерял парсер» и «потерял файл». Ограничения: записи держатся в памяти списком (потоковый режим в планах, --verify память не ест — читает только заголовки); наборы полей внутри пресетов захардкожены.

Число записей — это не полнота

Одна мысль, ради которой всё писалось:

Число записей, полученное от парсера без ошибки, ничего не говорит о полноте данных.

Парсер, вернувший 94 записи с кодом 0, и парсер, вернувший 2309 записей с кодом 0, для вызывающего кода неразличимы. Разница видна, только если спросить у самого файла, сколько записей он содержит, — и сравнить.

Практический минимум — сверка двух независимых источников. На Windows:

$ wevtutil gli /lf:true Sysmon.evtx | findstr numberOfLogRecords
numberOfLogRecords: 2309

Где угодно — пятнадцать строк из раздела «Как это проверять», без зависимостей и без Windows.

Если ваш пайплайн выдал другое число — это не «особенность реализации». Это ваш инцидент, разбираемый по 4% данных, в которых не было ни одного события атаки.

Проверьте у себя. Возьмите любой .evtx, снятый с живой машины, прогоните через свой обычный пайплайн и через check_evtx.py — и киньте в комментарии одну строчку в таком виде:

python-evtx 0.8.1 → 94   |   заголовки заявляют 2309   |   33 слота, 3 нечитаемых

Мне очень интересно, насколько это массово за пределами одного образа, и на каких инструментах расходится. Отдельно интересны Winlogbeat, NXLog, Velociraptor и всё, что тащит логи в SIEM само: у меня их под рукой нет, а вопрос «а что доехало до индекса» ровно тот же.

Линки: спецификация формата EVTX (libevtx) · python-evtx · omerbenamram/evtx · chainsaw analyse gaps · wevtutil