{"repro_id":"REPRO-2026-00134","version":8,"title":"lodash: prototype pollution in _.unset/_.omit deletes global prototype methods","repro_type":"security","status":"published","severity":"medium","description":"`lodash`'s `_.unset(object, path)` and `_.omit(object, paths)` accept a string\n`path`, parse it into segments (dot / bracket notation), and walk those\nsegments on the target object. Through `4.17.22` they do **not** reject\ndangerous segments such as `__proto__`, `constructor`, or `prototype`.\n\nBecause `someObject.__proto__` is the *live* `Object.prototype`, a crafted path\nstring lets an attacker steer `_.unset` out of the intended object and into a\nbuilt-in prototype, then **delete a method from it**. The deletion affects the\nprototype globally for the rest of the Node.js process, so every object of\nthat type loses the method — a denial-of-service / integrity issue\n(prototype pollution by deletion). It permits *deleting* members but not\noverwriting their behavior.\n\nAny code that forwards attacker-influenced key names into `_.unset` / `_.omit`\n(for example a property path taken from JSON request input) is exploitable.","root_cause":"# RCA Report: CVE-2025-13465\n\n## Summary\n\nCVE-2025-13465 is a prototype-pollution-by-deletion vulnerability in lodash's `_.unset` and `_.omit` functions. When a user-controlled string path containing `__proto__` is passed to these functions, lodash traverses into `Object.prototype` and deletes the targeted property globally. This affects every object in the Node.js process, causing denial-of-service or integrity degradation. The issue is fixed in lodash 4.17.23 by adding a guard in `baseUnset` that rejects dangerous string path segments (`__proto__`, `constructor.prototype`).\n\n## Impact\n\n- **Package**: `lodash` (npm)\n- **Affected versions**: 4.0.0 through 4.17.22 (inclusive)\n- **Fixed version**: 4.17.23\n- **Risk level**: Moderate (CVSS 3.1 base 5.3)\n- **Consequences**: Any application that forwards attacker-controlled key names into `_.unset` or `_.omit` (e.g., from JSON request input) can have built-in prototype methods deleted globally, leading to denial-of-service or unexpected behavior across the entire process.\n\n## Root Cause\n\nThe root cause lies in the `baseUnset` function (shared by `_.unset` and `_.omit`). Before the fix, `baseUnset` parsed the path string into segments and walked each segment on the target object without validating whether a segment was a dangerous prototype accessor such as `__proto__`, `constructor`, or `prototype`.\n\nBecause `someObject.__proto__` returns the live `Object.prototype`, a crafted path like `__proto__.toString` causes the function to traverse out of the intended object and into the global prototype, then delete the specified key from it.\n\n**Fix commit**: [`edadd452146f7e4bad4ea684e955708931d84d81`](https://github.com/lodash/lodash/commit/edadd452146f7e4bad4ea684e955708931d84d81)\n\nThe fix adds a loop in `baseUnset` that iterates over path segments and:\n1. Skips non-string keys.\n2. Blocks `__proto__` anywhere in the path if it is not an own property of the current object.\n3. Blocks `constructor.prototype` chains, with a narrow exception for primitive roots (e.g., `_.unset(0, 'constructor.prototype.a')`).\n\n## Reproduction Steps\n\nThe reproduction script is `repro/reproduction_steps.sh`.\n\nWhat the script does:\n1. Installs the last published vulnerable version (`lodash@4.17.21`) in a temporary directory.\n2. Runs a Node.js harness (`test_app.js`) that simulates an application receiving untrusted user input (`__proto__.toString`) and passing it to `_.unset({}, untrustedInput)`.\n3. Captures the `typeof Object.prototype.toString` before and after the call.\n4. Repeats steps 1–3 with the fixed version (`lodash@4.17.23`).\n5. Also runs a secondary check with `_.omit({}, '__proto__.toString')` on the vulnerable build.\n6. Writes a JSON runtime manifest (`repro/runtime_manifest.json`) with versions, exit codes, log paths, and explicit confirmation markers.\n\n**Expected evidence of reproduction:**\n- **Vulnerable build**: `typeof Object.prototype.toString` changes from `\"function\"` to `\"undefined\"`, and the harness exits with code `2` emitting `VULN_CONFIRMED`.\n- **Fixed build**: `typeof Object.prototype.toString` stays `\"function\"`, and the harness exits with code `0` emitting `FIX_CONFIRMED`.\n\n## Evidence\n\nLog files captured by the script:\n- `logs/vuln_unset.log` — Vulnerable `_.unset` test output.\n- `logs/fixed_unset.log` — Fixed `_.unset` test output.\n- `logs/vuln_omit.log` — Vulnerable `_.omit` test output.\n- `repro/runtime_manifest.json` — Structured manifest linking all evidence.\n\nKey excerpts from `logs/vuln_unset.log`:\n```\nlodash version: 4.17.21\nuntrusted path input: __proto__.toString\ntypeof Object.prototype.toString BEFORE: function\ntypeof Object.prototype.toString AFTER: undefined\nVULN_CONFIRMED: prototype pollution via _.unset in lodash 4.17.21\n```\n\nKey excerpts from `logs/fixed_unset.log`:\n```\nlodash version: 4.17.23\nuntrusted path input: __proto__.toString\ntypeof Object.prototype.toString BEFORE: function\ntypeof Object.prototype.toString AFTER: function\nFIX_CONFIRMED: prototype intact in lodash 4.17.23\n```\n\nKey excerpts from `logs/vuln_omit.log`:\n```\nlodash version: 4.17.21\ntypeof Object.prototype.toString BEFORE: function\ntypeof Object.prototype.toString AFTER: undefined\nVULN_CONFIRMED_OMIT: _.omit pollutes prototype\n```\n\nEnvironment:\n- Node.js v22.22.2\n- npm 10.9.7\n\n## Recommendations / Next Steps\n\n- **Upgrade guidance**: Projects using lodash 4.17.21 or earlier should upgrade to 4.17.23 or later immediately. Note that a separate array-path bypass (CVE-2026-2950) exists and is fixed in 4.18.0, so upgrading to 4.18.0+ is recommended for full protection.\n- **Code review**: Audit all calls to `_.unset` and `_.omit` that accept user-controlled path strings. If upgrading is not immediately possible, sanitize path segments to reject `__proto__`, `constructor`, and `prototype` before passing them to lodash.\n- **Testing**: Add regression tests that exercise `__proto__` and `constructor.prototype` paths against `_.unset` and `_.omit` to prevent future regressions.\n\n## Additional Notes\n\n- **Idempotency**: `repro/reproduction_steps.sh` has been executed twice consecutively with identical results (exit 0, same log outputs).\n- **Version caveat**: The ticket specifies 4.17.22 as the last vulnerable version, but this version was never published to npm or tagged in Git. The reproduction uses `4.17.21` as the vulnerable baseline because it is the last published version that exhibits the flaw. The fix commit `edadd452` is included in `4.17.23`, which is published and confirmed to block the attack.\n- **Edge cases**: The fix specifically targets *string* path segments. A later bypass using array paths (`['__proto__', 'toString']`) was assigned a separate CVE (CVE-2026-2950) and is out of scope for this ticket.\n","ghsa_id":"GHSA-xxjr-mmjv-4gpg","cve_id":"CVE-2025-13465","cwe_id":"CWE-1321","source_url":"https://github.com/lodash/lodash","package":{"name":"lodash","ecosystem":"npm","affected_versions":">= 4.0.0, <= 4.17.22","fixed_version":"4.17.23"},"reproduced_at":"2026-05-22T08:30:20.851824+00:00","duration_secs":1767.013619184494,"tool_calls":149,"turns":139,"handoffs":2,"total_cost_usd":1.2694910899999996,"agent_costs":{"repro":0.17500171000000003,"support":0.017008239999999997,"vuln_variant":1.07748114},"cost_breakdown":{"repro":{"accounts/fireworks/models/kimi-k2p6":0.17500171000000003},"support":{"accounts/fireworks/models/kimi-k2p6":0.017008239999999997},"vuln_variant":{"accounts/fireworks/models/kimi-k2p6":1.07748114}},"quality":{"idempotent_verified":false,"community_verifications":0},"published_at":"2026-05-22T08:30:22.755372+00:00","retracted":false,"artifacts":[{"path":"repro/rca_report.md","filename":"rca_report.md","size":5747,"category":"analysis"},{"path":"repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":5221,"category":"reproduction_script"},{"path":"vuln_variant/rca_report.md","filename":"rca_report.md","size":7541,"category":"analysis"},{"path":"vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":7210,"category":"reproduction_script"},{"path":"bundle/context.json","filename":"context.json","size":2935,"category":"other"},{"path":"bundle/metadata.json","filename":"metadata.json","size":780,"category":"other"},{"path":"bundle/ticket.md","filename":"ticket.md","size":4003,"category":"ticket"},{"path":"repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1254,"category":"other"},{"path":"repro/validation_verdict.json","filename":"validation_verdict.json","size":1787,"category":"other"},{"path":"vuln_variant/test_bypass.js","filename":"test_bypass.js","size":5435,"category":"other"},{"path":"vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":1256,"category":"other"},{"path":"vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":7213,"category":"documentation"},{"path":"vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":2652,"category":"other"},{"path":"vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":1926,"category":"other"},{"path":"vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":2842,"category":"other"},{"path":"vuln_variant/source_identity.json","filename":"source_identity.json","size":843,"category":"other"},{"path":"logs/vuln_omit.log","filename":"vuln_omit.log","size":171,"category":"log"},{"path":"logs/vuln_variant/vuln_test.log","filename":"vuln_test.log","size":143,"category":"log"},{"path":"logs/vuln_variant/latest_test.log","filename":"latest_test.log","size":131,"category":"log"},{"path":"logs/vuln_variant/fixed_test.log","filename":"fixed_test.log","size":579,"category":"log"},{"path":"logs/vuln_unset.log","filename":"vuln_unset.log","size":271,"category":"log"},{"path":"logs/fixed_unset.log","filename":"fixed_unset.log","size":254,"category":"log"}]}