MVF-2026-0013: a binary plist that shares its containers expanded exponentially

A binary plist stores its values once and refers to them by index, so the same object can be referenced from many places.

Identifier MVF-2026-0013
Internal row M26-ART-008
Severity S2, priority P1
Affected 2.3.0, 2.4.0, 2.5.0
Not affected 2.2.0 and every earlier release, where the binary plist parser did not exist
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#

A binary plist stores its values once and refers to them by index, so the same object can be referenced from many places. plist_parse expanded that reference graph into a tree, and the only thing bounding the expansion was a nesting-depth cap of 100. There was no set of already-active object identifiers, no memoisation and no visited-object cache.

A cycle therefore terminated — the depth cap caught it — but a shared subgraph did not need one. Object i an array of two references to object i + 1, twenty levels deep, is a million values from about 120 bytes, and each one was parsed afresh at every reference. The file grows by four bytes a level while the work doubles.

Impact#

The worse half of this is that it did not fail. Measured on 2.5.0's parser, with the file size shown and nothing else changed:

levels per file
71 B 8
87 B 12
103 B 16
111 B 18
119 B 20
levels file allocated returned
8 71 B 8,200 B no error
12 87 B 131,112 B no error
16 103 B 2,097,224 B (2.00 MiB) no error
18 111 B 8,393,808 B (8.00 MiB) no error
20 119 B 33,559,632 B (32.00 MiB) no error

Exactly four times the memory per two levels, and plist_parse returns a 32 MiB object tree from a 119-byte file as a success. Continuing the measured slope, 24 levels is about 512 MiB from 135 bytes and 30 levels about 32 GiB from 159 bytes — and a Go allocation that exceeds what the host can commit is a fatal runtime error that recover() cannot catch, so past some point the process does not return a value or an error but dies.

Sharing a string has the same shape without a cycle: 200 references to one 4 KiB string decoded that string 200 times.

An examiner running a script over a plist from the evidence chooses neither the file nor its reference graph. The entry point is plist_parse.

Why this is in scope#

SECURITY.md lists under "Memory safety of the VM and the parsers" a crafted input that drives a forensic parser into "an unbounded allocation", and states that "forensic input is hostile by definition". A well-formed plist — no cycles, nothing out of bounds, every offset valid — that costs 2^N to parse is that.

The fix, and the one that was rejected#

The obvious fix is to memoise parseObject by object index, so a shared subgraph is expanded once. That is not sufficient, and it was deliberately not done. The result of memoising is a directed acyclic graph, and every consumer of the parsed value — Inspect, printing, serialising, which is what putln reaches — walks it as a tree. The exponential cost moves downstream instead of going away. The external report measured exactly this on a memoising build: 24 levels from a 135-byte file parsed in 183 µs and then spent 3 minutes 4.87 seconds inside Inspect(), materialising 41,943,036 bytes.

What the parser does instead is charge every value produced against a budget, and the budget is the file's own size in bytes:

  • A reference costs at least one byte of file, so a plist whose containers are each listed once cannot describe more values than it has bytes. remaining starts at len(data) and is spent one per value produced; a plist that would expand past it is refused, because only sharing a container can make it do that. The refusal names the file's size and says so.
  • Leaves are memoised and containers are not. A string or data object is decoded once per object and shared, because sharing a string costs one reference and produces one value — so a megabyte string referenced a million times is a megabyte, where before it was a terabyte. Sharing a container is what multiplies values without paying bytes, so containers keep being counted.
  • A container that holds itself is named as that, through a set of the objects active on the path from the root, rather than being run down to the depth cap and reported as nesting.

This bound is proportional to the input rather than a constant, and it cannot refuse a legitimate file: the margin over the tightest legitimate shape — one leaf shared by every reference, which is what every real plist writer produces — is the fixed overhead of the magic, the offset table and the trailer, and it stays constant as references are added. Measured at 45 bytes of headroom for 1, 8, 100 and 200 references alike.

Fixtures#

All in builtin/plist_graph_test.go:

  • TestABinaryPlistThatSharesContainersIsRefusedNotExpanded — forty shared arrays, which did not return in two seconds on the unfixed parser.
  • TestABinaryPlistContainerThatHoldsItselfIsNamed — the error names the self-reference rather than the depth.
  • TestABinaryPlistMayShareWhatItsWritersShare — the negative case, so the budget cannot be tightened into refusing a real plist without a test failing.

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 and the external report is dated 2026-09-30; the two were found independently. The report carried a working patch and, with it, the argument set out above that memoisation alone does not fix this — which this project had reached separately, and which is recorded here because arriving at the same answer twice is worth more than either arrival alone. The report's own budget was a flat ceiling of 2^22 expansions; the fix shipped here spends one value per byte of file, which refuses the same 127-byte input in microseconds where a flat ceiling still allowed it 3.9 seconds of parsing and 10,485,756 bytes of output.

References#

  • SECURITY.md — "In scope", memory safety of the VM and the parsers.
  • Internal rows: M26-ART-008, and M26-ART-005 (MVF-2026-0012) for the header-count half of the same report.

Related reading