Триаж рабочей станции: журнал Sysmon, скрипт на python-evtx — всё как в сотне туториалов:
from Evtx.Evtx import Evtxwith 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 байт, дальше — 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_chunk00000010 00 00 00 00 00 00 00 00 current_chunk_num00000018 b9 2b 00 00 00 00 00 00 next_record_num = 1119300000020 80 00 00 00 01 00 03 00 hdr_size=128, minor=1, major=300000028 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» сама по себе ничего не значит:
|
|
файлов |
|---|---|
|
всего |
137 |
|
из них dirty ( |
26 |
|
из них многочанковых (реально больше одного chunk’а) |
6 |
|
многочанковых и dirty |
4 |
|
многочанковых, dirty и с заниженным |
3 |
Ключевой момент, который легко упустить: у 22 из 26 dirty-журналов физически один chunk. Занижать там нечего — chunk_count = 1 и есть правда, баг не проявляется. Проблема живёт только на журналах больше одного chunk’а, а это ровно те, где накопилось много событий, то есть самые интересные для расследования.
Все шесть, целиком:
|
Журнал |
|
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 записи и штатно завершилась. Она сделала ровно то, что от неё просили. Ошибки не было.
Замеры по всем шести многочанковым журналам:
|
Журнал |
|
|
Заявлено заголовками |
Потеряно |
|---|---|---|---|---|
|
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%
Это не абстрактная «часть данных». Вот атака, восстановленная по полному журналу. Время — от первой записи журнала, EventRecordID — подлинные:
|
|
Δ от начала |
Событие |
|---|---|---|
|
11193 |
0:00 |
первая запись журнала |
|
11286 |
+0:34 |
последняя запись, которую увидел |
|
12251 |
+9:39 |
|
|
12257 |
+10:14 |
|
|
12272 |
+11:54 |
|
|
12279 |
+12:09 |
|
|
12420 |
+12:54 |
|
|
12510 |
+15:17 |
сетевой сканер с рабочего стола — разведка сети |
|
12741 |
+17:59 |
|
|
12799 |
+18:19 |
|
|
12818 |
+19:16 |
|
|
13002 |
+26:44 |
|
|
13014 |
+27:14 |
|
|
13501 |
+45:07 |
последняя запись журнала |
Парсер остановился на записи 11286. Первое событие атаки — 12251. Между ними 964 записи, и ни одной из них он не увидел.
В прочитанных 94 записях нет вообще ничего: 34 секунды загрузочного шума, svchost, mscorsvw, компиляция нативных образов .NET. Скрипт отработал с кодом 0, выдал аккуратный список событий — и в этом списке двойное вымогательство выглядит как тишина.
Отдельно отмечу вторую строку таблицы потерь. TS-RCM — журнал, где живёт EID 1149, самое раннее свидетельство RDP-аутентификации. Атакующий добавил свою учётку в Remote Desktop Users; потеря 69% этого журнала бьёт ровно туда.
«Так это же документированное поведение»
Да, и докстринг честный:
If
include_inactiveis set to true, enumerate chunks beyond those declared in the file header (and may therefore be corrupt).
Для парсера общего назначения консервативный дефолт можно защитить: не лезть за пределы того, что заявил файл, — разумная позиция. Но в форензике этот дефолт меняет полноту на безопасность, не сообщая о размене ни строчкой. Ты не получаешь ни warning’а, ни ненулевого кода возврата — ты получаешь число, которое выглядит как ответ.
И вот что превращает это из «настраиваемого поведения» в грабли: флаг есть на уровне FileHeader, но не на уровне того класса, которым все пользуются.
|
Метод |
Сигнатура |
|---|---|
|
|
|
|
|
|
|
|
|
log.records(include_inactive=True) — это TypeError. Верхнеуровневый класс Evtx параметр не пробрасывает: его chunks() вызывает self._fh.chunks() без аргументов. Чтобы обойти дефолт, надо спуститься на уровень ниже — к FileHeader:
from Evtx.Evtx import Evtxwith 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, structfrom Evtx.Evtx import Evtxshutil.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 12307faked.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 в них будут сплошными. Обрезка чтения не создаёт дыр — она создаёт укороченный, но идеально непрерывный хвост.
Ровно одно исключение, и оно не в вашу пользу. Если журнал уже пошёл по кругу (oldest_chunk в заголовке не ноль), физически первые chunk’и — не самые старые, и чтение N первых слотов даст разрыв, который gap-анализ действительно увидит. Только python-evtx oldest_chunk не смотрит вовсе — он начинает от header_chunk_size() независимо от него, — так что вы получите записи не в том порядке и разрыв не там, где он есть на самом деле. Диагноз «кто-то вырезал записи» на таком выводе — ровно та ошибка, которой вы пытались избежать.
Поэтому:
-
Gap-анализ отвечает на вопрос «не вырезал ли кто-то записи из файла?»
-
Но есть и второй вопрос: «а прочитал ли мой парсер всё, что в файле есть?»
Это разные вопросы, и первый не покрывает второй. Проверять надо не выборку против самой себя, а выборку против независимого источника истины — того, что о содержимом файла говорят заголовки chunk’ов, минуя парсер записей.
И проверять надо свой пайплайн целиком, а не библиотеку из этой статьи. Дефолт python-evtx — конкретный пример, но вопрос «сколько записей в файле против сколько дошло до меня» осмыслен для любого парсера, включая ваш собственный.
Как это проверять
Заголовок каждого chunk’а (ElfChnk\x00) несёт две пары чисел:
|
Смещение |
Что там |
|---|---|
|
|
номера записей в chunk’е → ожидаемое количество |
|
|
сами |
В норме эти пары дублируют друг друга, так что считать их двумя независимыми доказательствами не стоит. Полезны они по-разному:
-
Количество. Суммируем по всем chunk’ам, сравниваем с тем, что реально отдал парсер. Расходится — парсер что-то потерял.
-
Диапазон. Из объявленного диапазона
EventRecordIDвычитаем фактически прочитанные ID. Остаток — конкретные пропавшие записи, поимённо.
Ключевое: обе цифры читаются из заголовков chunk’ов напрямую, отдельным проходом по файлу, а не через API парсера. Если спросить у парсера, полный ли он прочитал файл, он ответит «да» — по определению.
Отдельного инструмента для этого не нужно, пятнадцати строк хватает:
import struct, sysdef 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, badfor 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.evtxSysmon.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.16.1.7601.22948
Записи хранилища компонентов Windows Update. В хвосте CAPI2 лежит кусок реестра — и сам CAPI2 при этом абсолютно здоров и даже не dirty.
Значит, хвостовые слоты в принципе умеют содержать посторонний мусор. Место под слоты выделяется сильно вперёд записи, и это видно по всем шести журналам: у TS-RCM записано 4 chunk’а при 16 слотах в файле, у GroupPolicy — 5 при 17, у Sysmon — 30 при 33. Слоты, до которых служба журналирования ещё не дописала, обычно нулевые, но не обязаны ими быть: они хранят то, что лежало на этих кластерах раньше.
Теперь — почему в Sysmon это тоже не работа шифровальщика. Две причины, и обе видны только из полного журнала:
-
Шифровальщик показал свои цели сам. В его командной строке стоял явный
-p <каталог>, и каталога с журналами среди целей не было. -
Журнал продолжал нормально писаться ещё 18 минут после шифрования. Последний запуск шифровальщика — на отметке +27:14, последняя читаемая запись Sysmon — +45:07. Все записи в этом промежутке целы. Файл, который шифровальщик действительно затронул бы, так себя не ведёт.
Вывод: остаточные данные, а не уничтожение улик.
Но заметьте, ценой чего получен этот вывод. Обе причины извлечены из тех самых 96%, которые дефолтный парсер не прочитал. Разбирая этот образ по 94 записям, вы бы не увидели ни командной строки шифровальщика, ни того, что журнал жил после шифрования, — зато прекрасно увидели бы три слота с энтропией 7,996 в хвосте. И написали бы в отчёт уничтожение журналов, которого не было.
Обрезка чтения не просто теряет данные. Она забирает контекст, без которого оставшиеся данные интерпретируются неправильно.
И даже теперь я не могу доказать, что в этих слотах не было затёртых кем-то записей. Высокая энтропия одинаково объясняется и остатками старого архива, и целенаправленной перезаписью. Различить по одному файлу нельзя. Поэтому единственный корректный вердикт — не «OK» и не «повреждено атакующим», а «полнота непроверяема»: 30 читаемых chunk’ов заявляют 2309 записей, парсер прочитал 2309, счётчики сошлись — но что было в трёх нечитаемых слотах, не знает никто.
Отсюда же — главная ловушка для тех, кто пишет свою проверку: не останавливайте скан заголовков на первом непонятном слоте. Если прерваться, эталон полноты сам окажется обрезанным ровно там же, где споткнулся парсер записей: прочитано и заявлено занизятся согласованно и совпадут. Проверка выдаст OK именно на том классе файлов, ради которого она писалась. Ровно поэтому в скрипте выше стоят два continue, а не break.
Итого, четыре исхода — и выводы для расследования у них разные:
|
Что видим |
Что это значит |
|---|---|
|
прочитано = заявлено, заголовки все читаемы |
полнота подтверждена |
|
прочитано < заявлено |
потерял парсер: обрезка чтения, наш случай |
|
прочитано = заявлено, но в диапазоне дыры |
записей нет в самом файле: ротация или выборочное удаление — территория |
|
часть заголовков chunk’ов нечитаема |
эталон неполон: честный ответ не «OK», а «полнота непроверяема» |
Пятый случай — прочитано больше, чем заявлено, — тоже бывает: значит, заголовки chunk’ов устарели или повреждены и эталону доверять нельзя. Встречается редко, но обрабатывать его надо явно, иначе он маскируется под норму.
Как я свёл эту проверку в одну команду
Из этой истории выросла утилита — evtxview (Python, обёртка над rust-парсером evtx, MIT):
pipx install git+https://github.com/kotru21/evtx-viewer
Ничего принципиально нового по сравнению со скриптом выше она не делает — просто прогоняет ту же проверку по всем файлам сразу, различает четыре исхода из таблицы и называет пропавшие записи поимённо. Вывод на журналах образа, дословно:
$ evtxview logs/*.evtx --verifylogs/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 --verifylogs/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 numberOfLogRecordsnumberOfLogRecords: 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
ссылка на оригинал статьи https://habr.com/ru/articles/1063518/