
Windows event logs in EVTX format are a key source of telemetry for incident response. They provide evidence of attacker activity on a host and are often the only remaining record of what happened during account compromise, lateral movement, or persistence attempts.
Attackers often try to destroy these logs by clearing them with wevtutil cl, encrypting them, or wiping disks. Ransomware operators increasingly target entire virtual machine disk images, including VDI, VMDK, and VHDX files. The file system of the affected volume may become inaccessible or too badly damaged for standard tools to mount: for example, if the master file table (MFT) has been destroyed or the partition table is missing.
One option is to reconstruct the file system manually by locating lost partitions and recovering deleted files. However, this takes time and may still leave gaps in the event history or severely corrupted EVTX files. This is where carving comes in—a byte-level search for EVTX signatures in raw data from a disk or volume image, a memory dump, a pagefile, or a VSS snapshot. It can recover surviving event data without relying on the file system.
At the Positive Technologies Expert Security Center, our Incident Response team (PT ESC IR) prioritizes automated artifact parsing to detect malicious activity and reconstruct incidents faster. Our processing pipeline is written primarily in Go. We could not find a suitable open-source library that combined EVTX parsing and carving. Existing parsers either crash regularly or consume too much memory, which hinders automation. We developed our own library that parses intact EVTX files, recovers data even when checksums do not match or files are corrupted, and performs event carving from bit-for-bit copies, memory dumps, and virtual disk images.
To understand what carving can recover and why some methods reconstruct complete records while others retrieve only individual field values, we first need to examine the EVTX file format.
EVTX structure
An EVTX file has three nested layers: file → chunks → records.
File header
The file header occupies the first 4,096 bytes (0x1000). Only the first 128 bytes contain data; the rest are reserved and usually filled with zeros. The first 8 bytes of the file contain the header magic ElfFile\x00, followed by a structured block.
|
Offset |
Size |
Field |
Description |
|
0 |
8 |
Magic |
File signature: ElfFile\x00 |
|
8 |
8 |
FirstChunkNumber |
Number of the first chunk in the circular buffer |
|
16 |
8 |
LastChunkNumber |
Number of the last chunk |
|
24 |
8 |
NextRecordID |
ID to assign to the next record |
|
32 |
4 |
HeaderSize |
Size of the structured portion of the header, usually 128 bytes |
|
36 |
2 |
MinorVersion |
Minor format version |
|
38 |
2 |
MajorVersion |
Major format version, usually 3 |
|
40 |
2 |
HeaderBlockSize |
Size of the entire header block: 4,096 bytes |
|
42 |
2 |
ChunkCount |
Declared number of chunks |
|
44–119 |
76 |
— |
Reserved |
|
120 |
4 |
Flags |
Bit flags |
|
124 |
4 |
Checksum |
CRC32-IEEE of bytes [0:120] |
The Flags field of the EVTX file header indicates the log’s state: DIRTY (0x1) means the log was not closed cleanly, FULL (0x2) means the log has reached capacity and stopped accepting new events, and NO_CRC32 (0x4) means the application disabled checksum validation. However, when EVTX files are acquired by artifact collectors, the header data often remains unpopulated because the logging service has not yet flushed the metadata to disk. As a result, the header’s ChunkCount field may be unreliable. The only way to determine the file size is to multiply the number of chunks by 65,536 bytes and add 4,096 bytes.
Chunk header
A chunk is a fixed 65,536-byte block, where the first 512 bytes represent structured header data, and the remainder contains event records. Each chunk begins with the ElfChnk\x00 magic bytes.
|
Offset from chunk start |
Size |
Field |
Description |
|
0 |
8 |
Magic |
|
|
8 |
8 |
FirstEventRecordNumber |
Number of the first record in the chunk |
|
16 |
8 |
LastEventRecordNumber |
Number of the last record in the chunk |
|
24 |
8 |
FirstEventRecordID |
Global ID of the first record |
|
32 |
8 |
LastEventRecordID |
Global ID of the last record |
|
40 |
4 |
HeaderSize |
Chunk header size |
|
44 |
4 |
LastEventRecordDataOffset |
Offset of the start of the last record |
|
48 |
4 |
FreeSpaceOffset |
Offset of the start of free space |
|
52 |
4 |
EventsChecksum |
CRC32 checksum of all record data in the chunk |
|
56–119 |
64 |
— |
Reserved |
|
120 |
4 |
Flags |
Chunk bit flags |
|
124 |
4 |
HeaderChunkChecksum |
CRC32-IEEE checksum of the chunk header |
|
128 |
256 |
StringsOffsets[64] |
64 four-byte offsets pointing to the heads of the string cache’s hash buckets |
|
384 |
128 |
TemplateOffsets[32] |
32 four-byte offsets pointing to the heads of the template cache’s hash buckets |
The chunk header contains two independent CRC32 checksums: one for the header itself (bytes [0:120] and [128:512]), and another for the records area ([512:FreeSpaceOffset]). The fact that these are two distinct CRCs rather than a single global one is crucial for carving: a chunk might be corrupted in one section but intact in another, and these checksums allow us to verify them independently.
Event records
Now we reach the most complex part of EVTX parsing: decoding binary XML (BinXML). See complete description of tokens and fields here.
Event records are stored consecutively in the chunk’s data area.
An event record consists of a 24-byte header (containing the magic bytes 2A 2A 00 00, DataSize, EventRecordID, and FILETIME), a BinXML body, and a 4-byte tail that duplicates the DataSize. This trailing copy allows parsers to iterate through the chunk, verify record integrity by comparing the two values, and easily scan backward to locate record boundaries when carving through corrupted data. EventRecordID increases monotonically across the log. Gaps in the sequence can therefore indicate deleted or lost events. Individual records do not have their own CRC. The chunk’s EventsChecksum covers all its records, so checksum validation is possible only at the chunk level.
|
Offset from record start |
Size |
Field |
Description |
|
0 |
4 |
Magic |
Bytes 2A 2A 00 00 |
|
4 |
4 |
DataSize |
Total record size, including this header and the trailing copy |
|
8 |
8 |
EventRecordID |
Global, monotonically increasing record ID |
|
16 |
8 |
Timestamp |
|
|
24 |
DataSize−28 |
BinXML |
Event body in binary XML |
|
DataSize−4 |
4 |
DataSize (copy) |
The same |
The event header is followed by the BinXML body: a binary representation of XML built from single-byte tokens. Each token represents an XML construct. Storing a complete XML structure in every record, with every tag and attribute name written out as text, would make logs unnecessarily large. The same event, for example, event 4624 from Microsoft-Windows-Security-Auditing, may occur thousands of times with the same fields but different values. BinXML saves space in two ways.
Level 1: tokens instead of text The record body is a sequence of single-byte tokens rather than textual markup. Each token is a structural piece of XML:
|
Byte |
Token |
Description |
|
0x00 |
EOF |
End of fragment |
|
0x01 |
OpenStartElement |
<Tag |
|
0x02 |
CloseStartElement |
> (after attributes) |
|
0x03 |
CloseEmptyElement |
/> |
|
0x04 |
CloseElement |
</Tag> |
|
0x05 |
Value |
Typed value |
|
0x06 |
Attribute |
name=»value» |
|
0x07 |
CDataSection |
CDATA section |
|
0x08 |
CharRef |
Character reference |
|
0x09 |
EntityRef |
Entity reference |
|
0x0a/0x0b |
PITarget/PIData |
XML processing instructions |
|
0x0c |
TemplateInstance |
Template reference and substitution values |
|
0x0d/0x0e |
Substitution/SubstitutionOptional |
Regular substitution |
|
0x0f |
FragmentHeader |
BinXML fragment header |
Value is not just raw text; the data is prefixed by a single-byte type tag. This means the parser knows exactly how to handle the data before formatting it. These types include integers of various sizes and signs, HexInt32/64 (which are just UInt32/64 values formatted as 0x..., commonly used for Keywords), strings (both UTF-16 and single-byte), GuidType, SidType, FileTimeType, raw binary data, nested BinXML fragments (used when a value is a complex XML structure rather than a simple scalar), and array variants of all the above.
Level 2: templates. This level dictates what can actually be carved. In BinXML, tag or attribute names (ProcessId, TargetUserName, and so on) are almost never written in plain text. Instead, they are replaced by a 4-byte pointer to an entry in the chunk’s string cache. An event’s entire structure is only written out once per chunk. The first time a specific event type appears, a full BinXML tree is written. Instead of actual values, this tree contains substitution slots: Substitution (a required slot) or SubstitutionOptional (an optional slot that may be empty) tokens marked with an index. This tree is the template. Every subsequent event with the same structure is no longer a tree, but a short TemplateInstance token: a reference to the already saved template, plus an ordered list of specific values (type + data), one for each slot, in the order of their indices. To parse such a record, you take the template tree and insert the corresponding value into each slot based on its index (if an optional slot is empty, that node is simply removed from the tree; if an array value contains more than one element, it is expanded into multiple repeating elements instead of just one).
The space savings mentioned earlier are not an abstraction, but the result of chunk caches, whose header fields StringsOffsets and TemplateOffsets we saw above (in the chunk header). The 4-byte name pointer we discussed points to an entry in the string cache: this entry contains the next link in the hash bucket chain (only needed when writing the file to deduplicate identical names; ignored during reading), a 16-bit name hash, a character count, the actual UTF-16LE characters, and a terminating NUL. Reading a name is a single direct lookup at a known offset, not a hash table traversal.
The template cache operates on the same principle, but it stores entire XML trees instead of strings. A template definition includes a GUID, the data size, and the BinXML tree with its substitution slots, all located in the chunk’s data area. The template reference inside TemplateInstance is also a direct 4-byte offset to this tree. In both cases, the hash bucket «heads» in the chunk header (64 for strings, 32 for templates) are only needed by the writing application to find already saved strings or templates and avoid duplicates; reading code does not need them at all.
This leads to the core principle behind all carving: a field name is a reference to the chunk’s string cache, and without the chunk, it is unrecoverable. However, the substitution values inside TemplateInstance are self-contained and reside in the record body, regardless of whether the chunk is intact. They can be read even when the template, field names, and the entire chunk are lost. It is just impossible to know if a particular string was originally labeled TargetUserName or something else. This separation is the only thing that makes carving individual records possible, and both methods below rely on it.
Carving methods
Based on the structure of EVTX files (chunks and records), we can highlight several approaches to carving data from raw file system or RAM images. The first is aimed at reconstructing the most complete event structure possible, while the second focuses on extracting at least some information in cases of severe fragmentation or damage.
Chunk carving (full record structure reconstruction)
The method consists of scanning for whole or partial chunks via the ElfChnk\x00 signature without relying on alignment boundaries, subsequently evaluating the validity and recovering all records with their full field names.
Algorithm:
-
Searching for a chunk signature involves scanning the raw image for the byte sequence
ElfChnk\x00(0x45 0x6C 0x66 0x43 0x68 0x6E 0x6B 0x00) at any offset, without requiring alignment to a 64 KB boundary. -
Chunk data retrieval. Upon finding the signature, the available data block is read from the image: up to 65,536 bytes (a full chunk) or until the end of the image if the chunk is truncated.
-
Chunk header parsing. The chunk header is parsed: the magic bytes, version, and sizes are checked, and checksums are calculated (optional).
-
Confidence assignment. One of three confidence levels is assigned to the chunk based on CRC check results:
-
High: both the header and data checksums match perfectly.
-
Medium: only the header checksum matches (the header is intact, but the data is corrupted or truncated).
-
Low: checksums do not match, but the magic bytes and header structure look visually plausible.
-
-
Record parsing within the chunk (even at low confidence):
-
The record header (24 bytes) is read and
DataSizeis obtained. -
The
DataSizevalue from the header is compared with its duplicate in the 4-byte record tail. -
If the copies match, the record is considered valid; the BinXML body is extracted and decoded using the chunk’s own string and template caches.
-
If the copies do not match, a resync is performed: the next record signature is searched (
2A 2A 00 00) instead of halting or parsing «garbage».
-
-
Full record reconstruction. Because the chunk has its own template and string caches, every successfully extracted record receives all named fields and nested JSON, just as it would during the parsing of an intact file.
-
Metadata logging. For each record, the offset in the image, the offset of the owning chunk (if found), and the assigned confidence level are logged.
{ "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" } } } }
Record carving (no templates, but readable fields)
This method consists in searching for individual records using the 2A 2A 00 00 signature if chunks cannot be recovered. It extracts only the basic fields from the header and raw values from the record body, but without any mapping to names (templates).
Algorithm:
-
Signature search. The raw image is scanned for the byte sequence
2A 2A 00 00 (the record start signature) at an arbitrary offset. -
Record header extraction. From the 24 bytes following the signature, DataSize,
EventRecordID,FILETIME(timestamp), and other metadata fields are read. -
Record body extraction (BinXML). Based on
DataSizefrom the header, the record body containing the actual event data is read. -
Nameless value extraction.
-
Since the real values (PIDs, SIDs, paths, IP addresses, and text strings) are self-contained in the record body, they are extracted as an ordered list.
-
Important: field names (
ProcessId,LogonId, and so on) are lost because they are stored in the chunk cache, which is unavailable. -
The output is a set of values in the exact order they appeared in the original event, but without semantic labels.
-
-
Final stage (severe corruption). If the record body defies structural parsing, brute-force scan for readable UTF-16 text is performed without any structure, simply as the last effort to extract some information.
-
Metadata logging. For each recovered record, the offset in the image, the offset of the owning chunk (if identifiable), and the confidence level (which is generally lower than in the first method) are logged.
Carving compressed (LZNT1) chunks
The methods listed above stop working if the EVTX files reside on an NTFS volume with transparent compression enabled. In that case, what is physically stored on the disk is not the chunk itself, but an LZNT1 stream, from which the OS reconstructs the original bytes when reading the file through the file system.
Directly scanning a raw disk image for the ElfChnk\x00 signature and attempting to parse the structure will yield random matches, making it impossible to recover readable EVTX events.
However, a specialized carving solution for compressed NTFS files can be built by exploiting a key technical feature:
-
NTFS compresses data in 16-cluster blocks called Compression Units (CUs). Assuming a standard 4 KB cluster, a CU is exactly 65,536 bytes—a perfect match for the EVTX chunk size. Inside the CU, data is further divided into 4,096-byte subchunks. Each subchunk is either compressed independently or stored raw, and each gets its own 2-byte header. Because both the EVTX file header (4,096 bytes) and the chunk size (65,536 bytes) are multiples of 4,096, the start of any EVTX chunk will always align perfectly with a CU subchunk boundary.
-
In any LZ77-based compression stream (like LZNT1), the first output bytes are always literals because the match-search window starts out empty. For an EVTX chunk, those first bytes are the
ElfChnk\x00signature. Since LZNT1 uses a sliding window capped at 4,096 bytes, it can only look for matches within that vicinity.
Using these mechanics, we can recover compressed chunks and extract the EVTX events using this workflow:
-
Find candidates. The image is scanned for the
ElfChnk\x00string, just as in standard carving. -
Step back to the compression header. LZNT1 streams are broken into blocks, each starting with a 2-byte little-endian header. The lower 12 bits dictate the payload length, the next 3 bits hold a 011 signature, and the highest bit flags whether the block is compressed. If the block is compressed and begins with the
ElfChnk\x00signature, there will be an extra token flag byte right before the signature. This means the block header actually starts at offset -3 relative to the signature. If the block is uncompressed, that extra byte is not there, meaning the header starts at offset -2. Since we do not know whether the block is compressed or not in advance, both offsets are tested: first, offset -3, then, offset -2. -
Decompress the CU. Once we pinpoint the header, we decompress the LZNT1 stream until we have reconstructed a full 65,536-byte block.
-
Verification. Check the first 8 bytes of the decompressed output. If they do not match the
ElfChnk\x00signature, it is a false positive. If they do, the original 64 KB chunk is reconstructed. -
Reuse standard carving. Feed this decompressed chunk into the same record parsing engine used for uncompressed carving.
{ "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 }
Tools
EVTX tools are different in terms of both languages and performance. Some tools are full-fledged libraries, while others are just CLI wrappers. Carving is either absent or exists as a separate project loosely connected to the main parser.
|
Tool |
Language |
Format |
Carving |
|
evtx / evtx_dump (omerbenamram) |
Rust |
Crate (library) + CLI |
No, only basic recovery on evtx corruption |
|
Rust core + Python wrapper |
Python package |
No, basic recovery |
|
|
golang-evtx (0xrawsec) |
Go |
CLI evtxdump |
No |
|
Go |
Library + CLI (Velociraptor core) |
No |
|
|
libevtx (libyal) |
C |
Library + CLI |
No |
|
python-evtx (williballenthin) |
Pure Python |
Library + CLI |
No, requires the separate EVTXtract project |
|
EvtxECmd (Eric Zimmerman) |
C# / .NET |
CLI, not designed for import outside the .NET ecosystem |
No |
None of the reviewed options fully solve the problem: fast parsing plus carving as a library for a Go project. Among Go solutions, Velocidex/evtx runs stably but is slower than the rest, does not support carving, and reparses templates; golang-evtx by 0xrawsec can perform carving, but crashes with panic() at the slightest structural deviation; the crash reproduces even on a standard Windows log, which is unacceptable for corrupted files, and the carving function is not accessible from the imported package. The Rust library evtx/evtx_dump is the fastest, but connecting it to Go requires a separate process or cgo, and carving is listed as a basic feature marked as incomplete. In Python, the situation is no better: python-evtx is slow and lacks carving, EVTXtract exists separately, and the pyevtx-rs wrapper speeds up parsing but inherits the limitations of the Rust core. EvtxECmd in C#/.NET is considered an industry standard with an advanced Maps system, but it lacks native carving (recovery is offloaded to bulk_extractor) and is designed to run as a CLI, not an embeddable library. Using it from Go would require maintaining a separate process with a .NET runtime. Ultimately, there was no library that combined performance, robust carving of corrupted data, and native Go support.
go-evtx-carver is a Go library and CLI for parsing Windows Event Logs from EVTX files and carving EVTX chunks or records directly from raw images (disk dumps, memory dumps, pagefiles).
Key features:
-
Parse EVTX to JSON or JSON Lines using either buffering or streaming.
-
Resilience to corruption: a broken checksum of a chunk or a single broken record does not halt the parsing of the entire file. Only what is unreadable is lost; the rest is parsed normally.
-
Dual carving strategies when the EVTX file no longer exists: by chunks (reconstructing the full record structure, including field names) and by individual records (recovering field values without names, since field names are references in the chunk’s string cache, and the chunk is absent in this type of carving).
-
Carving chunks compressed with NTFS compression (LZNT1) yields the same result as chunk carving, but designed for cases where
winevt\Logsis located on a volume with compression enabled. -
A CLI wrapper over both modes.
The evtx and carve packages are at the top level of the module (not under internal/), so they can be imported externally as a standard library.
Parse: the simplest entry, a single call.
func Parse(path string, validateChecksums bool) ([]json.RawMessage, Stats, error)
Parses the entire file, buffering the entire result in memory. Returns the JSON of each record and final statistics. An error is returned only if the file could not be opened; parsing errors on individual records are not fatal and are tallied in Stats.ErrorCount.
records, stats, err := evtx.Parse("security.evtx", true) if err != nil { log.Fatal(err) } fmt.Printf("parsed %d records, errors: %d\n", stats.RecordCount, stats.ErrorCount) for _, r := range records { fmt.Println(string(r)) // r—json.RawMessage, one record }
ParseFileToJSONL: a streaming function for large files.
func ParseFileToJSONL(inputPath string, validateChecksums bool, warnOut io.Writer) (<-chan []byte, <-chan Stats, error)
Keeps only one 64 KB chunk in memory at a time instead of the whole file. The lines channel yields the JSON of each record (without \n) one by one, in sequential order, and closes upon completion.
lines, statsCh, err := evtx.ParseFileToJSONL("security.evtx", true, os.Stderr) if err != nil { log.Fatal(err) } for line := range lines { // Process line ([]byte, JSON of one record) immediately, without buffering into memory } stats := <-statsCh
The carve package is used for recovering data from raw images. It is used when a complete, valid EVTX file is unavailable (the disk is encrypted or the file system is corrupted, but a raw image or memory dump can be obtained). It scans bytes directly for EVTX signatures.
Carve is a dispatcher; the strategy is set by the value chunks or 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 is chunk carving, reconstructing the full EVTX structure.
func CarveChunks(imagePath string) ([]CarvedRecord, error)
It searches for chunk signatures and decodes records with their full structure, including field names (the owning chunk’s string cache is available). Because EVTX does not have a checksum for an individual record, only for the chunk as a whole, each result receives a Confidence score instead of a binary accept/reject.
|
Confidence |
Value |
|
ConfidenceChunkValidated |
Both chunk checksums (header and record data) are correct |
|
ConfidenceChunkHeaderOnly |
The header checksum is correct, but the data checksum is not (often a truncated tail) |
|
ConfidenceChunkUnverified |
The signature or header was recognized, but the header checksum failed |
A chunk with a bad checksum is not discarded; it is still parsed, just with a lowered Confidence.
CarveRecordsByMagic is carving by individual records.
func CarveRecordsByMagic(imagePath string) ([]CarvedRecord, error)
It scans the image for the record signature (0x2A 0x2A 0x00 0x00) independently of the owning chunk, meaning it works even when the chunk is lost or corrupted, but the record itself is physically intact. Field names cannot be recovered (they are references in the chunk’s string cache), but substitution values are recovered positionally, in the order they were declared (Values), with the type of each value alongside it (ValueTypes, for example «UInt16«). This serves as a hint for matching a position to a field like EventID (which is almost always UInt16), but it is not a guarantee. Such records receive Confidence == ConfidenceRecordEnvelope. If a record cannot be parsed even as a TemplateInstance, a fallback raw text sweep (RawStrings) is used.
CarveChunksLZNT1 is carving chunks compressed with NTFS compression (LZNT1).
func CarveChunksLZNT1(imagePath string) ([]CarvedRecord, error)
It is used when winevt\Logs is located on an NTFS volume with transparent compression (LZNT1) enabled: in this case, the chunk bytes on the disk are not the data itself, but a compression stream, and standard CarveChunks will find nothing there (the chunk signatures physically do not exist in the compressed bytes).
CarvedRecord is the common result structure for these strategies. Only EventRecordID and Timestamp are guaranteed to be populated (taken from the record header); the rest are filled as possible:
type CarvedRecord struct { ImageOffset int64 // Offset of the record header in the image ChunkOffset int64 // Offset of the owning chunk, or -1 (CarveRecordsByMagic) EventRecordID uint64 Timestamp time.Time JSON []byte // Full record JSON populated by CarveChunks and CarveChunksLZNT1 Values []string // Positional substitution values populated only by CarveRecordsByMagic ValueTypes []string // wire-type of each Values[i], in the same order RawStrings []string // Fallback raw text, if Values is not populated CompressedSourceOffset int64 CompressedSourceBytes int Confidence Confidence Note string // Explanation if JSON is empty or Confidence is lowered }
For CLI mode, the tool can be run in the following ways.
Parsing an EVTX file or all EVTX files in a directory into JSON Lines:
./evtx -out ./output security.evtx ./evtx -validate-checksums -out ./output C:\logs
Carving from a raw image:
./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 # For NTFS-compressed winevt\Logs
Conclusion
EVTX logs remain a valuable source of telemetry—and a target for attackers seeking to erase traces of their activity. When logs have been cleared or the file system is corrupted, analysts may need to extract whatever data remains directly from the raw bytes. In these situations, carving can recover evidence that standard file parsing cannot.
Existing EVTX tools generally handle standard file parsing well, with implementations available in most major programming languages, including high-performance implementations in Rust. Support for carving, however, is fragmented across standalone projects, often written in a different language or producing output in a different format from the same developer’s main parser. We built the go-evtx-carver library to bridge this gap. It combines standard parsing, chunk-based carving that reconstructs complete record structures, and record-level carving as a fallback—all in a single pure-Go module, with no cgo dependency.
ссылка на оригинал статьи https://habr.com/ru/articles/1091918/