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 offsetIntSize and objectRefSize themselves 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.

Related reading