//nefariousplan

CVE-2026-8161: multiparty Used `{}` as a Map for Twelve Years and Six Months

pattern

cve

proof of concept

CVE-2026-8161 is a one-request denial of service against multiparty, the multipart parser pillarjs maintains for the Express ecosystem. The exploit is a multipart field whose name is __proto__. The parser reads fields["__proto__"], gets Object.prototype, calls .push() on it, throws TypeError, and the Node process exits. The line that did this was committed on 25 October 2013, in a refactor titled change the form.parse callback API yet again. It shipped through every release for the next twelve years and six months.

The object literal as map

The vulnerable lines, index.js before commit 8ee6aca:

var fields = {};
var files = {};
self.on('field', function(name, value) {
  var fieldsArray = fields[name] || (fields[name] = []);
  fieldsArray.push(value);
});
self.on('file', function(name, file) {
  var filesArray = files[name] || (files[name] = []);
  filesArray.push(file);
});

fields and files are initialized to {} higher in Form.prototype.parse. The contract the developer wrote into the variable names is "string-to-array map." The contract the language gives them is "object literal," which is not the same thing. This is the object-literal-as-map shape: a JavaScript handler reaches for {} to model a dictionary, and the language's default container hands back the prototype chain on attacker-controlled keys.

fields[name] does a property lookup. If the lookup misses on the object's own keys, it walks the prototype chain. The chain ends at Object.prototype, which carries twelve inherited keys:

> Object.getOwnPropertyNames(Object.prototype)
[ '__proto__', 'constructor', 'hasOwnProperty', 'isPrototypeOf',
  'propertyIsEnumerable', 'toLocaleString', 'toString', 'valueOf',
  '__defineGetter__', '__defineSetter__',
  '__lookupGetter__', '__lookupSetter__' ]

Send a multipart field whose name is any of those, and the lookup returns a non-undefined inherited value. When the inherited value is truthy, fields[name] || (fields[name] = []) short-circuits. The assignment never runs. fieldsArray is bound to whatever the prototype chain returned. Then .push(value) is called on it.

For name = "__proto__", the bound value is Object.prototype. Object.prototype.push is undefined. Calling undefined as a function throws TypeError: fieldsArray.push is not a function. The throw originates inside a 'field' event handler, which the parser invokes synchronously during emit. The throw propagates out of the EventEmitter, escapes the stream's error path because it never enters the _write callback chain, and reaches Node's uncaughtException. The default behaviour exits the process.

The PoC is one HTTP request. The --replica counter is theatre.

$ python3 secdos.py -u http://localhost:8080/api/submit
Sending request #0
status code: 502
Collision payload accepted. [PASS]

The 502 is the Nginx gateway reporting that its upstream stopped answering. Its upstream is no longer running.

The PoC sends one of twelve crash primitives

The PoC's payload chose __proto__ because that name is the most legible. Eleven other field names crash the parser by the same path. Each names a property the developer never stored.

> ({}).hasOwnProperty
[Function: hasOwnProperty]
> ({}).hasOwnProperty.push
undefined
> ({}).toString.push
undefined
> ({}).valueOf.push
undefined
> ({}).constructor.push
undefined

hasOwnProperty, toString, valueOf, isPrototypeOf, propertyIsEnumerable, toLocaleString, constructor, the four __define/__lookup accessor pairs, every one is a truthy reference whose .push is undefined. Each is a one-request DoS against the same line. A patch that closed only the __proto__ literal would have left the bug class open. The patch did the right thing and gated the lookup on hasOwnProperty.call, which excludes every inherited key.

The CVE description names __proto__ because that is what the PoC sends. The bug names every key on the prototype chain.

The first commit subject describes a different fix

The merged patch is 8ee6aca09aca29e923da48da8260c87aa964b282. Its body lists four squashed PR subjects in order:

* fix: prevent prototype pollution by initializing files object with null prototype
* fix: safely handle __proto__ field name in form parsing
* use hasOwnPropert
* remove comment

The first subject describes a fix that uses Object.create(null). That fix would replace var fields = {} with var fields = Object.create(null), producing a prototype-less container with no inherited keys to reach. The landed code does not do this. fields and files are still {}. The patch instead gates the lookup:

-      var fieldsArray = fields[name] || (fields[name] = []);
-      fieldsArray.push(value);
+      var fieldsArray = Object.prototype.hasOwnProperty.call(fields, name)
+        ? fields[name]
+        : undefined;
+      if (Array.isArray(fieldsArray)) {
+        fieldsArray.push(value);
+      } else {
+        fields[name] = [value]
+      }

hasOwnProperty.call(fields, name) returns false for any inherited key. The conservative read closes the parser-side crash. It does not change what the parser hands back. cb(null, fields, files) still hands a plain {} to user code. Any consumer that does fields[someUserSuppliedKey] without the same hasOwnProperty.call gate is back in the same lookup, in user code, post-parse. The patch closed the parser's crash. The default container still ships with twelve inherited keys downstream.

The first subject also says "files object" singular. The author of that PR was thinking only about the file-upload path. The reviewer expanded the scope to cover both fields and files and chose the more conservative read-gate over the prototype-stripped container. The merge subject was never updated. The squashed history is the record of the fix evolving during review; the landed code is the conservative end of that evolution.

Twelve years and six months

git log -S "fields[name] || (fields[name]" against the multiparty repository returns two commits. The second is the May 2026 fix. The first is the October 2013 introduction:

commit 81e6e2b8aa885f02ea2532856c95cbddeb1962de
Author: Andrew Kelley <[email protected]>
Date:   Fri Oct 25 15:10:31 2013 -0400

    change the form.parse callback API yet again. closes #23

The refactor renamed fieldsTable and filesTable to fields and files, and changed the callback signature from five arguments to three. As part of that rename, the assignment pattern changed from fieldsTable[name] = value (which overwrites, a different bug class) to fields[name] || (fields[name] = []) (which short-circuits into the prototype chain). The change was about callback ergonomics. The prototype-chain hazard was incidental to the rename and was never noted in review.

__proto__ as an accessor on Object.prototype was already de facto in V8 in 2013 and had been since 2010. The hazard of using {} as a key-value map with attacker-controlled keys had been documented in JavaScript style guides for years before the refactor. The line was reviewed, merged, and tagged. Every subsequent release inherited it. The version most production deployments are running, [email protected], contains it unchanged. The fix is in 4.3.0, which was tagged the same day the patch merged.

The same week, pillarjs landed two other "Merge commit from fork" patches against the same index.js: a ReDoS in the Content-Type and filename-parameter regexes (commit e61a0b5), and a decodeURI that throws on malformed UTF-8 in the filename*=utf-8'' parameter (commit a7445dd). Three independently-discovered bug classes in one parser, all merged on 11 May 2026. The pattern is not "this CVE." The pattern is "nobody had audited this code in years, and when somebody did, the parser produced three CVEs in an afternoon."

The CVE description says denial of service. The merge commit said prototype pollution.

The NVD record for CVE-2026-8161 reads: Denial of service through an uncaught exception. The CVSS vector is AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H, confidentiality and integrity scored zero, availability high. The merged commit's first PR subject reads: prevent prototype pollution by initializing files object with null prototype.

Both descriptions are about the same prototype-chain leak. The CVE measures it by the exit code of the Node process. The fork author measured it by the lookup that touched Object.prototype. The patch landed a control that addresses the read site only. The container that produced the read is unchanged. A consumer of [email protected] that takes the returned fields object and runs an unguarded fields[req.query.key] against attacker input has the same primitive in their handler that the parser had in its 'field' listener for twelve years.

PoC: Ser0n-ath/CVE-2026-8161

The line shipped on 25 October 2013 and was deleted on 11 May 2026. Nothing about the JavaScript language changed in between.