MVF-2026-0012: a header count sized an allocation before the bytes were there
Five places in the artifact parsers pre-sized a slice from a count read straight out of the file being parsed.
| Identifier | MVF-2026-0012 |
| Internal row | M26-ART-005 |
| Severity | S2, priority P1 |
| Affected | 2.3.0, 2.4.0, 2.5.0 |
| Not affected | 2.2.0 and every earlier release, where none of these three parsers existed |
| Fixed in | 2.6.0 |
| Reported | externally, and independently by internal audit; filed 2026-09-25, external report dated 2026-09-30 |
| Published | 2026-10-06 |
| CVE / GHSA | none assigned |
| CVSS:3.1 | CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H |
| CVSS:4.0 | CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:A/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N |
Summary#
Five places in the artifact parsers pre-sized a slice from a count read straight out of the file being parsed. The loop that filled the slice was bounds-checked against the real buffer, so nothing was ever read out of bounds — but the allocation came first, and it was sized by a number the author of the file chose.
So the memory a parse asked for grew with a header field, not with the file. A few dozen bytes of crafted artifact could ask for gigabytes.
| the count | where it was read | what it sized |
|---|---|---|
| a key's value count | builtin/hive_builtins.go:170 |
make([]*vkValue, 0, nk.valueCount), 8 bytes each |
| a big-data record's size | builtin/hive_builtins.go:218 |
make([]byte, 0, size), up to 2 GiB |
| the DestList entry total | builtin/jumplist_builtins.go:176 |
make([]destEntry, 0, total), 56 bytes each |
| a binary plist's object count | builtin/plist_builtins.go:216 |
make([]int, numObjects), a uint64 handed to make |
| a binary plist array's element count | builtin/plist_builtins.go:289 |
make([]object.Object, 0, count), 16 bytes each, at every nesting level |
Impact#
A Go allocation that exceeds what the host can commit is a fatal runtime
error, and recover() cannot catch it. Every entry point here wraps itself in
a recover, so the parsers look defended and are not: the process does not return
a Mutant error, it dies.
The reachable surface is hive_open, hive_list_values, every hive-backed
reg_* read, amcache_parse, shimcache_parse, jumplist_parse and
plist_parse — which is to say, a script that opens an artifact it was given.
Forensic input is hostile by definition, and an examiner does not choose the
bytes in the evidence.
Measured, with the file size held fixed while the header field moved:
| input | the field | allocated |
|---|---|---|
| a 4,256-byte hive | value count 2^22, then 2^24 | 32 MiB, then 128 MiB |
| a 24 KB hive returning 20 KB | declared big-data size 64 MiB, then 256 MiB | 64 MiB, then 256 MiB |
| a 32-byte DestList stream | entry total 2^20, then 2^22 | 56 MiB, then 224 MiB |
| a 48-byte plist | object count 2^22, then 2^24 | 32 MiB, then 128 MiB |
| a 55-byte plist | array count 2^20, then 2^22 | 16 MiB, then 64 MiB |
| a 48-byte plist, array referencing itself | array count 2^14, then 2^16 | 25 MiB, then 101 MiB |
The last row is the one that multiplies: the allocation is repeated at every nesting level up to the depth cap of 100, so the cap bounded the depth and not the memory — 101 allocations of the same attacker-chosen size. Re-measured on 2026-10-06 at 6.31 MiB, 25.26 MiB and 101.00 MiB for counts of 2^12, 2^14 and 2^16, from a file that stayed 48 bytes throughout.
The external report ran three of these to the fatal error through the real
mutant binary, from a compiled artifact rather than a Go test, which is the
step from "a test can allocate this" to "a script an examiner runs can be killed
by a file".
Why this is in scope#
SECURITY.md lists under "Memory safety of the VM and the parsers": a crafted
disk image, registry hive or EVTX log that drives a forensic parser into "an
out-of-bounds read, an unbounded allocation, or a panic that escapes as anything
other than a Mutant error", and states that "a parser that trusts a length field
is a bug, not a limitation". This is that, five times.
The fix#
Every pre-size is now bounded by what the bytes present can actually hold,
checked before make rather than after:
- the value count against the length of the cell it was read from;
- the big-data size against the segments that exist and the file's own length;
- the DestList total against the number of entries the stream has room for;
- the plist object count against the bytes after the offset table's start, with
offsetIntSizeandobjectRefSizethemselves range-checked, and the top object required to be in range; - the plist array and dictionary counts against the references that are there to read, so an impossible count is refused by name instead of allocated for.
A count larger than the file can justify now produces a Mutant error naming the count, not an allocation.
Fixtures#
builtin/plist_graph_test.go—TestABinaryPlistIsNotSizedByItsCounts, with a subtest for the trailer's object count and one for an array's count, each asserting the allocation stays under a megabyte for a file of a few dozen bytes.builtin/hive_cells_test.go—TestAHiveIsNotSizedByTheCountsItRecords.builtin/jumplist_builtins_test.go—TestParseDestListIsNotSizedByItsCount, which sets the header's entry count to 2^24 over a stream holding one entry and asserts both that the one entry comes back and that under a megabyte is allocated.
Each measures runtime.MemStats.TotalAlloc across the call, so the test fails
on the unfixed parser by allocating rather than by returning the wrong answer.
Credit#
Reported by Pranjal, a BTech student in his fifth semester at the National Forensic Sciences University (NFSU). He asked to be credited publicly, and the name and affiliation are printed at his request.
The internal row was filed on 2026-09-25 from an audit of the same files, five days before the external report is dated, and the two were found independently. That is recorded here rather than used to reduce the credit: the report established through the real binary what the row had only extrapolated, scored each case, and — in the part of it that this project values most — made one of its own claims smaller, reporting the direct-cycle case as already bounded so the finding would not be overstated.
That sub-case was re-tested on 2026-10-06 before this page was written. The report is correct about the input it ran, which allocates 3,248 bytes: its array declares two elements, so the 101 nesting levels cost two slots each. The row's claim is the same cycle with the count read out of the file, which is a different input and is not bounded. Both statements are true, and both inputs are refused by the fix.
References#
SECURITY.md— "In scope", memory safety of the VM and the parsers.- Internal rows: M26-ART-005, and M26-ART-008 (MVF-2026-0013) for the expansion half of the same report.