Журналы событий Windows в формате EVTX являются одним из ключевых источников телеметрии при реагировании на инциденты. Они содержат свидетельства действий злоумышленника на хосте и нередко оказываются единственным подтверждением произошедшего. Например, при компрометации учетной записи, горизонтальном перемещении или закреплении.

Поэтому атакующие часто стремятся уничтожить их: очищают через wevtutil cl, шифруют или вайпят диски. Современные вымогатели все чаще шифруют целые образы виртуальных машин (VDI, VMDK, VHDX). В таких случаях файловая система тома может быть полностью недоступна или повреждена настолько, что штатные средства не могут ее смонтировать: например, если разрушена MFT или потеряна таблица разделов.

В подобной ситуации можно попытаться реконструировать файловую систему вручную: выполнить поиск потерянных разделов и восстановить удаленные файлы. Однако часть записей событий при этом может быть потеряна, и сам EVTX-файл нередко оказывается сильно поврежденным, а на такую работу уходит много времени. Здесь и вступает в дело карвинг — побайтовый поиск сигнатур формата EVTX непосредственно в сыром образе (диска, тома, дампа памяти, файла подкачки, снимка VSS) с восстановлением того, что физически сохранилось, в обход файловой системы.

Мы, команда реагирования на киберинциденты (PT ESC IR), уделяем приоритетное внимание автоматизации разбора артефактов для последующего обнаружения вредоносной активности — это ускоряет восстановление картины инцидента. Наш пайплайн преимущественно реализован на Go. Среди открытых решений нет качественной библиотеки, которая сочетала бы парсинг EVTX-файлов и карвинг в одном инструменте. Существующие парсеры либо регулярно падают, либо потребляют слишком много памяти. Это препятствует автоматизации, поэтому мы разработали собственную библиотеку. Она позволяет разбирать готовые EVTX-файлы, восстанавливая данные даже при несовпадении контрольных сумм или повреждении файла, а также выполнять карвинг событий из побитовых копий, дампов оперативной памяти и образов виртуальных дисков.

Однако, чтобы понимать, что вообще поддается восстановлению с помощью карвинга и почему одни подходы дают полную структуру записи, а другие — лишь отдельные значения полей, необходимо сначала разобраться во внутреннем устройстве EVTX-файлов.

Структура EVTX

Файл EVTX состоит из трех вложенных уровней: файл → чанки (chunks)→ записи (records).

Заголовок файла

Заголовок файла — 4096 (0x1000) байт в начале файла, но данные содержат только 128 байт, остаток зарезервирован и обычно заполнен нулями. Первые 8 байт файла содержат магию заголовка ElfFile\x00, дальше идет структурированный блок.

Смещение

Размер

Поле

Описание

0

8

Magic

ElfFile\x00 — сигнатура файла

8

8

FirstChunkNumber

Номер первого (по кольцевому буферу) чанка

16

8

LastChunkNumber

Номер последнего чанка

24

8

NextRecordID

ID, который получит следующая запись

32

4

HeaderSize

Размер структурированной части заголовка (обычно 128)

36

2

MinorVersion

Младшая версия формата

38

2

MajorVersion

Старшая версия формата (обычно 3)

40

2

HeaderBlockSize

Размер всего блока заголовка (4096 байт)

42

2

ChunkCount

Заявленное число чанков

44–119

76

Зарезервировано

120

4

Flags

Битовые

124

4

Checksum

CRC32-IEEE от байтов [0:120]

Поле Flags заголовка EVTX-файла может принимать разные значения: DIRTY (0x1) — журнал не был штатно закрыт, FULL (0x2) — журнал заполнен и не принимает события, NO_CRC32 (0x4) — проверка контрольных сумм отключена приложением. Однако при сборе файлов EVTX сборщиками артефактов данные заголовка часто остаются незаполненными, поскольку служба записи еще не сбросила метаданные на диск. Из-за этого поле ChunkCount в заголовке недостоверно, а единственный точный способ определить размер файла — умножить количество чанков на 65 536 байт и прибавить 4096 байт.

Заголовок чанка

Чанк — фиксированный блок размером 65 536 байт, где первые 512 байт — это структурированные данные заголовка, оставшаяся часть — записи событий. Начинается чанк с магических байтов ElfChnk\x00.

Смещение (от начала чанка)

Размер

Поле

Описание

0

8

Magic

ElfChnk\x00

8

8

FirstEventRecordNumber

Номер первой записи в чанке

16

8

LastEventRecordNumber

Номер последней записи в чанке

24

8

FirstEventRecordID

Глобальный ID первой записи

32

8

LastEventRecordID

Глобальный ID последней записи

40

4

HeaderSize

Размер заголовка чанка

44

4

LastEventRecordDataOffset

Смещение начала последней записи

48

4

FreeSpaceOffset

Смещение до свободной области

52

4

EventsChecksum

CRC32 по данным всех записей чанка

56–119

64

Зарезервировано

120

4

Flags

Битовые флаги чанка

124

4

HeaderChunkChecksum

CRC32-IEEE заголовка чанка

128

256

StringsOffsets[64]

64 указателя (по 4 байта) — головы хеш-бакетов кэша строк

384

128

TemplateOffsets[32]

32 указателя (по 4 байта) — головы хеш-бакетов кэша шаблонов

В заголовке чанка содержатся две независимые контрольные суммы CRC32: одна по самому заголовку (байты [0:120] и [128:512]), вторая — по области записей [512:FreeSpaceOffset] То, что это два разных CRC, а не один общий, важно именно для карвинга: чанк может быть поврежден в одной части и цел в другой, и об этом можно узнать раздельно.

Записи событий

Далее идет самый сложный этап при парсинге файла EVTX, а именно разбор бинарного XML (BinXML). Полное описание тегов и полей представлено тут.

Внутри области данных чанка записи событий идут подряд.

Запись события состоит из 24-байтного заголовка (магия 2A 2A 00 00, DataSize, EventRecordID, FILETIME), тела BinXML и 4-байтового хвоста, дублирующего DataSize. Хвостовая копия позволяет итерировать по чанку, проверять целостность записи (сравнением двух копий) и эффективно искать границы записей при карвинге поврежденных данных сканированием назад. EventRecordID монотонно возрастает по всему журналу, поэтому пропуски в его последовательности явно указывают на удаленные или утраченные события. У отдельной записи нет собственной CRC — единственная контрольная сумма (EventsChecksum) покрывает все записи чанка целиком, что ограничивает верификацию конкретной записи только в контексте всего чанка.

Смещение (от начала записи)

Размер

Поле

Описание

0

4

Magic

Байты 2A 2A 00 00

4

4

DataSize

Полный размер записи, включая этот заголовок и хвостовую копию

8

8

EventRecordID

Глобальный монотонно возрастающий номер записи в журнале

16

8

Timestamp

FILETIME — момент создания записи

24

DataSize−28

BinXML

Тело события в бинарном XML

DataSize−4

4

DataSize (копия)

Тот же DataSize, продублированный в конце

После заголовка события идет его описание в формате BinXML — бинарное представление XML или поток однобайтовых токенов, каждый из которых кодирует один структурный элемент XML. Если бы каждая запись хранила свою XML-структуру полностью, с именами всех тегов и атрибутов текстом, то размер журналов был бы слишком велик: одно и то же событие (скажем, 4624 от Microsoft-Windows-Security-Auditing) повторяется тысячи раз с одинаковым набором полей и разными значениями. BinXML экономит место на двух независимых уровнях, которые сейчас обсудим.

Уровень 1 — поток токенов вместо текста. Тело записи — это последовательность однобайтовых токенов, а не текстовая разметка. Каждый токен — один структурный кусочек XML:

Байт

Токен

Описание

0x00

EOF

Конец фрагмента

0x01

OpenStartElement

<Тег

0x02

CloseStartElement

> (после атрибутов)

0x03

CloseEmptyElement

/>

0x04

CloseElement

</Тег>

0x05

Value

Типизированное значение

0x06

Attribute

имя="значение"

0x07

CDataSection

Раздел CDATA

0x08

CharRef

Ссылка на символьный объект

0x09

EntityRef

Ссылка на символьную сущность

0x0a/0x0b

PITarget/PIData

Целевые

инструкции по обработке XML

0x0c

TemplateInstance

Ссылка на шаблон + значения подстановки

0x0d/0x0e

Substitution/SubstitutionOptional

Обычная подстановка

0x0f

FragmentHeader

Заголовок фрагмента BinXML

Value — не просто текст: перед данными идет однобайтовый тег типа. Поэтому любой парсер знает, что перед ним, еще до того, как начнет форматировать: числа разной разрядности и знаковости, HexInt32/64 (то же число, что и UInt32/64, но рендерится как 0x... — так закодирован, например, Keywords), строки (UTF-16 и однобайтовые), GuidType, SidType, FileTimeType, сырые бинарные данные, вложенный BinXML-фрагмент (когда значение само является XML-структурой, а не скаляром) и версии всех этих типов в виде массивов.

Уровень 2 — шаблоны. Именно этот уровень определяет, что можно карвить. Имя тега или атрибута (ProcessId, TargetUserName и так далее) в BinXML почти никогда не пишется текстом на месте — вместо него лежит 4-байтовая ссылка на запись в кэше строк того же чанка. А структура события целиком записывается только один раз на чанк: при первом появлении события такого типа в чанк кладется полное дерево BinXML, где на месте каждого переменного поля стоит не значение, а слот подстановки — токен Substitution (обязательный слот) или SubstitutionOptional (необязательный, может остаться пустым), помеченный порядковым индексом. Это дерево со слотами и есть шаблон (template). Каждое следующее событие той же структуры — это уже не дерево, а короткий токен TemplateInstance: ссылка на уже записанный шаблон плюс упорядоченный список конкретных значений (тип + данные), по одному на каждый слот, в порядке их индексов. Разобрать такую запись — значит взять дерево шаблона и подставить в каждый слот соответствующее по индексу значение (пустое значение необязательного слота — просто убрать этот узел из дерева, а значение-массив длиннее одного элемента — развернуть в несколько повторяющихся элементов вместо одного).

Экономия места, о которой шла речь выше, — не абстракция, а прямая работа кэшей чанков, чьи заголовочные поля StringsOffsets и TemplateOffsets уже встречались выше (заголовок чанка). 4-байтовая ссылка на имя, о которой шла речь, указывает прямо на запись в кэше строк: следующее звено цепочки того же хеш-бакета (нужно только при записи файла — для дедупликации одинаковых имен, при чтении не используется), 16-битный хеш имени, счетчик символов, сами символы в UTF-16LE и завершающий NUL. Прочитать имя — это одно прямое обращение по известному смещению, а не обход хеш-таблицы.

Кэш шаблонов устроен так же, но хранит не строки, а целые деревья: определение шаблона — это GUID, размер данных и дерево BinXML со слотами подстановки, лежащее прямо в области данных чанка. Ссылка на шаблон изнутри TemplateInstance — это тоже прямое 4-байтовое смещение на это дерево. В обоих случаях «головы» хеш-бакетов в заголовке чанка (64 для строк, 32 для шаблонов) нужны только записывающему приложению, чтобы находить уже сохраненные строки или шаблоны и не плодить дубликаты; читающему коду они не требуются вовсе.

Отсюда прямо следует то, на чем построен весь блок про карвинг: имя поля — это ссылка в кэш строк чанка, и без самого чанка оно принципиально невосстановимо. А вот значения подстановки в TemplateInstance самодостаточны и лежат прямо в теле записи независимо от того, жив ли чанк, — их можно прочитать, даже когда шаблон, имена полей и весь чанк уже потеряны. Нельзя только узнать, что именно это значение называлось TargetUserName, а не как-то иначе. Это разделение — единственное, что делает карвинг отдельных записей вообще возможным, и на нем построены оба метода ниже.

Методы карвинга

На основе структуры EVTX-файлов (чанки и записи) можно выделить несколько подходов к карвингу данных из сырых образов файловой системы или оперативной памяти. Первый ориентирован на восстановление максимально полной структуры событий, второй — на извлечение хотя бы части информации в случае сильной фрагментации или повреждений.

Карвинг по чанку с полным восстановлением структуры записи

Суть метода заключается в поиске целых или частичных чанков по сигнатуре ElfChnk\x00 без привязки к выравниванию, с последующей оценкой достоверности и восстановлением всех записей с полными именами полей.

Алгоритм:

  1. Поиск сигнатуры чанка — сканирование сырого образа на наличие байтовой последовательности ElfChnk\x00 (0x45 0x6C 0x66 0x43 0x68 0x6E 0x6B 0x00) на любом смещении, без требования выравнивания по границе 64 КБ.

  2. Извлечение данных чанка. При обнаружении сигнатуры из образа читается доступный блок данных — до 65 536 байт (полный чанк) или до конца образа, если чанк обрезан.

  3. Разбор заголовка чанка. Парсится заголовок чанка: проверяются магия, версия, размеры, а также вычисляются контрольные суммы (необязательно).

  4. Присвоение уровня доверия. Чанку назначается один из трех уровней в зависимости от результатов проверки CRC:

    • Высокий — контрольные суммы заголовка и данных совпали полностью.

    • Средний — совпала только сумма заголовка (заголовок цел, но данные повреждены или обрезаны).

    • Низкий — контрольные суммы не совпали, но магия и структура заголовка визуально правдоподобны.

  5. Разбор записей внутри чанка (даже при низком уровне доверия):

    • Чтение заголовка записи (24 байта), получение DataSize.

    • Сверка значения DataSize из заголовка с его дубликатом в 4-байтовом хвосте записи.

    • Если копии совпадают, запись считается валидной, извлекается тело BinXML и декодируется с использованием кэша строк и шаблонов самого чанка.

    • Если копии расходятся, выполняется ресинхронизация: поиск следующей сигнатуры записи (2A 2A 00 00) вместо остановки или разбора «мусора».

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

  7. Сохранение метаданных. Для каждой записи фиксируются смещение в образе, смещение владеющего чанка (если найден) и присвоенный уровень доверия.

{
 "image_offset": 538784256,
 "chunk_offset": 538783744,
 "event_record_id": 138544,
 "timestamp": "2026-01-12T14:11:59.4098826Z",
 "confidence": "chunk-validated",
 "event": {
     "Event": {
         "System": {
             "Provider": {
                 "#attributes": {
                     "Name": "Microsoft-Windows-Security-Auditing",
                     "Guid": "53842615-5478-4994-A5BA-3E3B0328C30D"
                 }
             },
             "EventID": 4624,
             "Version": 2,
             "Level": 0,
             "Task": 12544,
             "Opcode": 0,
             "Keywords": "0x8020000000000000",
             "TimeCreated": {
                 "#attributes": {
                     "SystemTime": "2026-01-12T14:11:59.409882Z"
                 }
             },
             "EventRecordID": 678691369,
             "Correlation": null,
             "Execution": {
                 "#attributes": {
                     "ProcessID": 892,
                     "ThreadID": 3612
                 }
             },
             "Channel": "Security",
             "Computer": "[REDACTED]",
             "Security": null
         },
         "EventData": {
             "SubjectUserSid": "S-1-0-0",
             "SubjectUserName": "-",
             "SubjectDomainName": "-",
             "SubjectLogonId": "0x0",
             "TargetUserSid": "S-1-5-21-812312269-519249661-100024111-1234",
             "TargetUserName": "[REDACTED]",
             "TargetDomainName": "[REDACTED]",
             "TargetLogonId": "0x2eb610",
             "LogonType": 3,
             "LogonProcessName": "Kerberos",
                 "AuthenticationPackageName": "Kerberos",
             "WorkstationName": "-",
             "LogonGuid": "323F37B0-63AB-CF9F-2CDE-3A6F84C3A512",
                 "TransmittedServices": "-",
             "LmPackageName": "-",
             "KeyLength": 0,
             "ProcessId": "0x0",
             "ProcessName": "-",
             "IpAddress": "[REDACTED]",
             "IpPort": "56272",
             "ImpersonationLevel": "%%1833",
                 "RestrictedAdminMode": "-",
                 "TargetOutboundUserName": "-",
                 "TargetOutboundDomainName": "-",
             "VirtualAccount": "%%1843",
                 "TargetLinkedLogonId": "0x0",
             "ElevatedToken": "%%1842"
         }
     }
 }
 }

Карвинг по записям (без разбора шаблонов, но с читаемыми полями)

Суть метода заключается в поиске отдельных записей по сигнатуре 2A 2A 00 00 в условиях, когда чанки не восстанавливаются. Извлекаются только базовые поля из заголовка и сырые значения из тела записи, но без привязки к именам (шаблонам).

Алгоритм:

  1. Поиск сигнатуры записи. Сканирование сырого образа на наличие байтовой последовательности 2A 2A 00 00 (сигнатура начала записи) на произвольном смещении.

  2. Извлечение заголовка записи. Из 24 байт, следующих за сигнатурой, читаются DataSize, EventRecordID, FILETIME (метка времени) и другие служебные поля.

  3. Извлечение тела записи (BinXML). На основе DataSize из заголовка читается тело записи, содержащее фактические данные события.

  4. Извлечение значений полей без имен:

    • Поскольку реальные значения (PID, SID, пути, IP-адреса, текстовые строки) лежат в теле записи самодостаточно, они извлекаются в виде упорядоченного списка.

    • Важно: имена полей (ProcessId, LogonId и т. п.) теряются, так как они хранятся в кэше чанка, который недоступен.

    • На выходе — набор значений в том порядке, в котором они следовали в оригинальном событии, но без смысловых подписей.

  5. Финальный этап (при сильных повреждениях). Если тело записи не поддается структурному разбору, выполняется грубое сканирование на читаемый UTF-16-текст — без какой-либо структуры, просто как последняя возможность извлечь хоть какую-то информацию.

  6. Сохранение метаданных. Для каждой восстановленной записи фиксируются смещение в образе, смещение владеющего чанка (если удалось определить) и уровень доверия (как правило, он ниже, чем у первого метода).

Карвинг сжатых (LZNT1) чанков

Перечисленные методы перестают работать, если файлы EVTX лежат на NTFS-томе с включенным прозрачным сжатием: тогда физически на диске хранится не сам чанк, а поток LZNT1, из которого ОС на лету восстанавливает исходные байты при чтении файла через файловую систему.

Данные из сырого образа диска
Данные из сырого образа диска

Прямой поиск сигнатуры ElfChnk\x00 в образе и дальнейший разбор структуры в таком случае дадут случайные совпадения, и восстановить читаемые события EVTX не представляется возможным.

Но есть ключевой факт, на котором можно построить решение карвинга при сжатом файле в NTFS:

  1. NTFS сжимает данные блоками Compression Unit (CU) размером 16 кластеров — при кластере 4 КБ это ровно 65 536 байт, что совпадает с размером чанка EVTX. Но внутри CU данные дополнительно разбиты на подчанки CU фиксированного размера — ровно 4096 байт исходных (распакованных) данных каждый, независимо сжатые или хранимые как есть, каждый со своим 2-байтным заголовком. Поскольку и заголовок EVTX-файла (4096 байт), и размер чанка (65 536 байт) кратны этим 4096 байтам, начало любого чанка в файле всегда попадает ровно на границу какого-то подчанка CU.

  2. У любого LZ77-потока (LZNT1 — его разновидность) первые байты вывода всегда являются литералами, поскольку окно поиска совпадений в начале пустое. А первые байты чанка — это сигнатура ElfChnk\x00. LZNT1 использует скользящее окно поиска с максимальным размером 4096 байт. Алгоритм может находить совпадения только в пределах этих данных.

На основе этих важных моментов можно восстановить сжатые чанки и в дальнейшем извлечь события EVTX по следующему алгоритму:

  1. Поиск кандидатов. Сканируем образ на строку ElfChnk\x00 — так же, как при обычном карвинге.

  2. Отступить к заголовку сжатых данных. Формат LZNT1 устроен таким образом, что данные состоят из последовательности блоков, каждый из которых начинается с 2-байтного little-endian-заголовка. В этом заголовке младшие 12 бит − длина полезной нагрузки, следующие 3 бита − сигнатура 011, старший бит − флаг сжатия. Если блок сжат и его первые байты — сигнатура ElfChnk\x00, то перед ней находится дополнительный байт флагов токенов. В этом случае начало блока равно смещению до сигнатуры -3. Если блок не сжат, дополнительного байта флагов нет, и начало блока равно смещению до сигнатуры -2. Поскольку заранее неизвестно, сжат блок или нет, проверяются оба варианта: сначала со смещением -3, затем − со смещением -2.

  3. Распаковать CU целиком. После того как определили позицию начала заголовка, начинаем распаковывать LZNT1 до накопления 65 536 байт.

  4. Проверка. Первые 8 байт распакованных данных должны совпасть с сигнатурой ElfChnk\x00. Если нет − кандидат ложный. Если да — получен восстановленный чанк размером 65 536 байт, идентичный оригинальному.

  5. Переиспользование обычного карвинга. Восстановленный чанк подается в ту же функцию разбора записей, что и при несжатом карвинге.

{
   "image_offset": 2395870593,
   "chunk_offset": 2395853161,
   "event_record_id": 25,
   "timestamp": "2026-08-12T20:24:15.836566Z",
   "confidence": "chunk-validated",
   "event": {
     "Event": {
       "System": {
         "Provider": {
           "Name": "Microsoft-Windows-Program-Compatibility-Assistant",
           "Guid": "4CB314DF-C11F-47D7-9C04-65FB0051561B"
     },
         "EventID": 17,
         "Version": 0,
         "Level": 4,
         "Task": 0,
         "Opcode": 0,
         "Keywords": "0x4000000000000000",
         "TimeCreated": {
           "SystemTime": "2026-08-12T20:24:15.836566Z"
     },
         "EventRecordID": 25,
         "Correlation": null,
         "Execution": {
           "ProcessID": 3804,
           "ThreadID": 10884
     },
         "Channel": "Microsoft-Windows-Application-Experience/Program-Compatibility-Assistant",
         "Computer": "[REDACTED]",
         "Security": {
           "UserID": "S-1-5-18"
     }
   },
       "UserData": {
         "ResolverFiredEvent": {
           "ExePath": "C:\\Users\\[REDACTED]\\Desktop\\mimikatz.exe",
           "ResolverName": "DetectorShim_KernelDriver"
     }
   }
 }
   },
   "note": "recovered by decoding LZNT1 (NTFS compression) starting at image offset 2395853161 (6699 compressed bytes -> 65536 bytes)",
   "compressed_source_offset": 2395853161,
   "compressed_source_bytes": 6699
 }

Инструменты

Экосистема инструментов для EVTX неоднородна и по языкам, и по скорости. Где-то это полноценная библиотека, где-то — CLI-обвязка над ней, а карвинг почти везде либо отсутствует, либо существует отдельным, слабо связанным с основным парсером проектом.

Инструмент

Язык

Форма поставки

Карвинг

evtx / evtx_dump (omerbenamram)

Rust

крейт (библиотека) + CLI

Нет, только базовое восстановление, при повреждении evtx

pyevtx-rs

Rust-ядро + Python-обертка

Python-пакет

Нет, базовое восстановление

golang-evtx (0xrawsec)

Go

CLI evtxdump

Нет

Velocidex/evtx

Go

Библиотека + CLI (ядро Velociraptor)

Нет

libevtx (libyal)

C

Библиотека + CLI

Нет

python-evtx (williballenthin)

чистый Python

Библиотека + CLI

Нет, для этого есть отдельный проект EVTXtract

EvtxECmd (Eric Zimmerman)

C# / .NET

CLI, не рассчитан на импорт извне NET-экосистемы

Нет

Ни одна из рассмотренных реализаций не решает задачу полностью — быстрый парсинг плюс карвинг в виде библиотеки для Go-проекта. Среди Go-решений картина такая: Velocidex/evtx работает стабильно, но медленнее остальных, не поддерживает карвинг и повторно разбирает шаблоны; golang-evtx от 0xrawsec умеет проводить карвинг, однако падает с panic() при малейших отклонениях в структуре данных — краш воспроизводится даже на обычном Windows-журнале, что неприемлемо для поврежденных файлов, а сама функция карвинга недоступна из импортируемого пакета. Rust-библиотека evtx/evtx_dump — самая быстрая, но подключать ее к Go можно только через отдельный процесс или cgo, а карвинг заявлен как базовая возможность и помечен как незавершенный. В Python ситуация не лучше: python-evtx медленный и без карвинга, EVTXtract существует отдельно, а обертка pyevtx-rs ускоряет разбор, но наследует ограничения Rust-ядра. EvtxECmd на C#/.NET считается отраслевым стандартом с развитой системой Maps, однако не имеет собственного карвинга — восстановление отдано на откуп bulk_extractor — и рассчитан на работу как CLI, а не как встраиваемая библиотека; для использования из Go потребуется держать отдельный процесс с .NET-рантаймом. В итоге потребность в библиотеке, которая сочетала бы производительность, устойчивый карвинг поврежденных данных и возможность прямого импорта в Go, остается незакрытой.

go-evtx-carver — библиотека и CLI на Go для разбора журналов событий Windows из файла EVTX и для карвинга EVTX-чанков или записей напрямую из сырых образов (дисковые дампы, дампы памяти, page file).

Возможности:

  • Разбор EVTX в JSON или JSON Lines потоково или буферизованно.

  • Устойчивость к повреждениям: битая контрольная сумма чанка или отдельная сломанная запись не обрывают разбор всего файла — теряется только то, что действительно не читается, остальное разбирается как обычно.

  • Карвинг двумя стратегиями, когда файла EVTX как такового больше нет: по чанкам — с восстановлением полной структуры записи, включая имена полей, по отдельным записям — восстанавливает значения полей без имен (имена полей — ссылки в кэше строк чанка, а чанка при таком карвинге нет).

  • Карвинг по чанкам, сжатым NTFS-компрессией (LZNT1), — тот же результат, что и в случае карвинга по чанкам, но для случая, когда winevt\Logs расположен на томе с включенным сжатием.

  • CLI-обертка над обоими режимами.

Пакеты evtx и carve — на верхнем уровне модуля (не под internal/), поэтому импортируются извне как обычная библиотека.

Parse — самый простой вход, один вызов.

func Parse(path string, validateChecksums bool) ([]json.RawMessage, Stats, error)

Разбирает файл целиком, буферизуя весь результат в память. Возвращает JSON каждой записи и итоговую статистику. Ошибка — только если файл не удалось открыть; ошибки разбора отдельных записей не фатальны и учтены в Stats.ErrorCount.

records, stats, err := evtx.Parse("security.evtx", true)
 if err != nil {
     log.Fatal(err)
 }
 fmt.Printf("разобрано %d записей, ошибок: %d\n", stats.RecordCount, stats.ErrorCount)
 for _, r := range records {
     fmt.Println(string(r)) // r — json.RawMessage, одна запись
 }

ParseFileToJSONL — потоковый вариант для больших файлов

func ParseFileToJSONL(inputPath string, validateChecksums bool, warnOut io.Writer) (<-chan []byte, <-chan Stats, error)

Держит в памяти один чанк 64 КБ за раз вместо всего файла. Канал lines отдает JSON каждой записи (без \n) по одной, в порядке следования, и закрывается по завершении

lines, statsCh, err := evtx.ParseFileToJSONL("security.evtx", true, os.Stderr)
 if err != nil {
     log.Fatal(err)
 }
 for line := range lines {
     // обработать line ([]byte, JSON одной записи) сразу, не накапливая в память
 }
 stats := <-statsCh

Пакет carve — восстановление из сырых образов. Используется, когда нет корректного EVTX-файла целиком (диск зашифрован или повреждена файловая система, но можно получить сырой образ или дамп памяти) — сканирует байты напрямую на сигнатуры EVTX.

Carve — диспетчер, стратегия задается значением chunks или records.

func Carve(imagePath string, mode Mode) ([]CarvedRecord, error)

const (
    ModeChunks      Mode = "chunks"       // CarveChunks
    ModeRecords     Mode = "records"      // CarveRecordsByMagic
    ModeChunksLZNT1 Mode = "chunks-lznt1" // CarveChunksLZNT1
)

recs, err := carve.Carve("disk.dd", carve.ModeChunks)

CarveChunks — карвинг по чанкам, с восстановлением полной структуры EVTX.

func CarveChunks(imagePath string) ([]CarvedRecord, error)

Ищет сигнатуры чанков и декодирует записи с полной структурой, включая имена полей (доступен кэш строк владеющего чанка). У EVTX нет контрольной суммы на отдельную запись, только на чанк целиком, поэтому каждый результат получает Confidence вместо бинарного accept/reject.

Confidence

Значение

ConfidenceChunkValidated

Верны обе контрольные суммы чанка (заголовка и данных записей)

ConfidenceChunkHeaderOnly

Верна контрольная сумма заголовка, данных — нет (часто оборванный хвост)

ConfidenceChunkUnverified

сигнатура или заголовок распознаны, но контрольная сумма заголовка не сошлась

Чанк с плохой контрольной суммой не отбрасывается — он все равно разбирается, просто с пониженным Confidence.

CarveRecordsByMagic — карвинг по отдельным записям.

func CarveRecordsByMagic(imagePath string) ([]CarvedRecord, error)

Сканирует образ на сигнатуру записи (0x2A 0x2A 0x00 0x00) независимо от владеющего чанка — то есть работает и когда чанк утерян или поврежден, а сама запись физически цела. Имена полей восстановить нельзя (они — ссылки в кэше строк чанка), но значения подстановок восстанавливаются позиционно, в порядке объявления (Values), с типом каждого значения рядом (ValueTypes, например "UInt16") — подсказка для сопоставления позиции с полем вроде EventID (почти всегда UInt16), но не гарантия. Такие записи получают Confidence == ConfidenceRecordEnvelope. Если запись не удается разобрать даже как TemplateInstance, используется резервный сырой sweep текста (RawStrings).

CarveChunksLZNT1 — карвинг чанков, сжатых NTFS-компрессией (LZNT1).

func CarveChunksLZNT1(imagePath string) ([]CarvedRecord, error)

В случае, когда winevt\Logs расположен на NTFS-томе с включенным прозрачным сжатием (LZNT1): в этом случае байты чанка на диске — не сами данные, а поток сжатия, и обычный CarveChunks там ничего не находит (сигнатуры чанка в сжатых байтах физически нет).

CarvedRecord общая структура результата стратегий — гарантированно заполнены только EventRecordID и Timestamp (берутся из заголовка записи), остальное по возможности:

type CarvedRecord struct {
     ImageOffset int64 // смещение заголовка записи в образе
     ChunkOffset int64 // смещение владеющего чанка, или -1 (CarveRecordsByMagic)

     EventRecordID uint64
     Timestamp     time.Time

     JSON       []byte   // полный JSON записи — заполняет CarveChunks и CarveChunksLZNT1
     Values     []string // значения подстановок позиционно — заполняет только CarveRecordsByMagic
     ValueTypes []string // wire-тип каждого Values[i], тот же порядок
     RawStrings []string // резервный сырой текст, если Values не заполнен

     CompressedSourceOffset int64
     CompressedSourceBytes  int

     Confidence Confidence
     Note       string // пояснение, если JSON пуст или Confidence снижен
 }

Для работы в режиме CLI есть следующие способы запуска.
Разбор EVTX-файла или всех EVTX-файлов в директории — в JSON Lines:

./evtx -out ./output security.evtx
 ./evtx -validate-checksums -out ./output C:\logs

Карвинг из сырого образа:

./evtx -mode chunks   -out result.jsonl disk.dd
 ./evtx -mode records  -out result.jsonl disk.dd
 ./evtx -mode chunks-lznt1 -out result.jsonl disk.dd  # winevt\Logs со сжатием NTFS

Заключение

EVTX остается одним из немногих источников телеметрии, которые злоумышленник физически не может обойти стороной, и одним из первых артефактов, которые он попытается уничтожить. Когда это удается — диск зашифрован, файловая система повреждена или журнал вычищен — перед аналитиком встает вопрос о возможности извлечь хоть что-то из сырых байтов. В таких сценариях работает только карвинг.

Экосистема инструментов для EVTX неплохо закрывает первую половину задачи: парсинг корректных файлов доступен на всех основных языках, а Rust-реализация здесь вне конкуренции по скорости. Вторая же половина — карвинг — оказалась разбросана по отдельным, слабо поддерживаемым проектам, часто на другом языке и с иным форматом вывода, отличным от основного парсера того же автора. Библиотека go-evtx-carver была создана именно для того, чтобы ликвидировать этот разрыв одним Go-модулем без cgo: штатный разбор, карвинг по чанкам с полной структурой и карвинг по отдельным записям без шаблонов — все под одним импортом.

Про техники карвинга — восстановление данных (записей EVTX, атрибутов MFT, PE-файлов, кустов реестра и текстовых фрагментов) из образов дисков с почти полностью разрушенной или зашифрованной файловой системой — можно также почитать еще в одном нашем материале.  

Комментарии (0)