Lodash is one of the most depended-upon packages in the JavaScript ecosystem, pulled transitively into an enormous share of Node.js and frontend builds. That ubiquity is exactly what makes its prototype pollution history worth understanding: a class of bugs that let an attacker-controlled object graph reach into Object.prototype and rewrite behavior for every object in the process, not just the one being manipulated. Across several CVEs disclosed between 2018 and 2020 — most notably CVE-2018-3721, CVE-2019-10744, and CVE-2020-8203 — lodash's deep-merge and deep-assignment utilities (merge, mergeWith, defaultsDeep, zipObjectDeep, and the set/setWith family) were shown to accept attacker-supplied keys like __proto__ or constructor.prototype and use them to write arbitrary properties onto the global prototype chain. Depending on how the polluted prototype is later consumed by application logic, the practical impact ranges from denial of service (crashing the process via an unexpected property) to authorization bypass and, in the presence of a suitable "gadget" in downstream code, remote code execution.
What prototype pollution actually does here
JavaScript objects inherit from Object.prototype unless explicitly created otherwise. Lodash's recursive merge and deep-set functions were designed to make it easy to combine nested objects — a common pattern for merging configuration, request bodies, or default options. The vulnerable versions did not block special property names during recursion, so a payload such as:
{"constructor": {"prototype": {"isAdmin": true}}}
or the more direct
{"__proto__": {"isAdmin": true}}
passed into _.merge({}, userInput), _.defaultsDeep({}, userInput), or _.zipObjectDeep(keys, values) could walk up the prototype chain and set isAdmin on Object.prototype itself. Every plain object in the running process — including ones that had nothing to do with the original merge call — would then inherit isAdmin: true unless it defined its own property of that name. If any code elsewhere checked user.isAdmin for an access-control decision, that check could now silently pass for every user. This is what distinguishes prototype pollution from a typical injection bug: the blast radius is the entire runtime, not the object under direct attack.
Affected versions and components
- CVE-2018-3721 —
merge,mergeWith, anddefaultsDeepin lodash before 4.17.5. This was the first widely publicized lodash prototype pollution report and prompted the initial hardening of the merge internals. - CVE-2019-10744 —
defaultsDeepin lodash before 4.17.12. The 4.17.5 fix turned out to be incomplete; researchers found a bypass usingconstructor.prototypetraversal that the earlier patch didn't fully close. - CVE-2020-8203 —
zipObjectDeepin lodash before 4.17.19. A different entry point (pairing an array of keys with an array of values) reached the same underlying weakness in deep-assignment logic.
All three affect the core lodash npm package and its scoped/modular variants (lodash.merge, lodash.defaultsdeep, lodash.zipobjectdeep, lodash-es), which are versioned and patched independently of the main package. This is a critical detail for remediation: fixing the top-level lodash dependency does not automatically fix a project that also pulls in an outdated lodash.merge submodule, and many older Express, Grunt, and build-tool plugins bundled these submodules directly. Because lodash sits three, four, or five layers deep in typical dependency trees (via frameworks, test runners, and bundler plugins), most organizations were exposed through transitive dependencies they never chose directly.
CVSS, EPSS, and KEV context
- CVE-2018-3721 carries a CVSS v3 base score in the medium range (commonly scored around 5.6), reflecting that exploitation requires an application to pass attacker-controlled input into the vulnerable merge functions and to have prototype-pollution-sensitive logic downstream.
- CVE-2019-10744 is rated substantially higher — critical severity (CVSS v3 base score of 9.1–9.8 depending on scoring source) — because
defaultsDeepis frequently used to merge request bodies or configuration with defaults, a pattern common enough in Express middleware and CLI tools that reachable, exploitable instances were plentiful. - CVE-2020-8203 is rated high (CVSS v3 base score 7.4), reflecting a comparable but slightly narrower attack surface tied to
zipObjectDeep's specific calling convention.
None of the three CVEs currently appears in CISA's Known Exploited Vulnerabilities (KEV) catalog, and public EPSS scores for this cluster have generally sat in the low-to-moderate percentile range rather than the top tier reserved for actively, opportunistically exploited bugs. That is not the same as "safe to ignore": low EPSS reflects the difficulty of building a generic internet-scale scanner for prototype pollution (exploitability is highly application-specific), not the absence of real-world impact. Security teams should treat EPSS here as a prioritization signal among many findings, not as a substitute for asking whether their own code actually reaches the vulnerable call with attacker-influenced data — which is precisely the question generic scanners can't answer and reachability analysis can.
Timeline
- 2018 — CVE-2018-3721 disclosed; lodash 4.17.5 released with the first prototype-pollution guard in
merge/mergeWith/defaultsDeep. - July 2019 — CVE-2019-10744 disclosed after researchers demonstrated a bypass of the 4.17.5 fix via
constructor.prototype; lodash 4.17.12 closes the gap. - May 2020 — CVE-2020-8203 disclosed, identifying
zipObjectDeepas a separate, previously unpatched entry point into the same class of bug; lodash 4.17.19 (and later 4.17.20/4.17.21) ship the fix, alongside a broader hardening pass across deep-assignment helpers. - 2020–present — Downstream advisories continue to surface as maintainers of packages bundling older
lodash.*submodules or vendored copies catch up; GitHub Security Advisories and Snyk's vulnerability database track the long tail of transitive re-exposure.
Remediation steps
- Upgrade to lodash 4.17.21 or later across every place it appears — top-level dependency, transitive dependency overrides, and any standalone
lodash.merge,lodash.defaultsdeep,lodash.zipobjectdeep, orlodash.setsubmodules. Usenpm ls lodash/yarn why lodashor your SBOM tooling to enumerate every version actually installed, not just the one inpackage.json. - Pin or override transitive versions. Where a framework or build plugin bundles an old lodash submodule and hasn't released a fix, use
overrides(npm),resolutions(Yarn), orpnpm.overridesto force the patched version at the resolution level rather than waiting on every maintainer in the chain. - Never pass untrusted input directly into deep-merge or deep-set operations, even on a patched lodash. Validate and allow-list keys before merging request bodies, query strings, or YAML/JSON configuration into objects that inform application state.
- Freeze prototypes defensively.
Object.freeze(Object.prototype)(andArray.prototype,Function.prototypeas needed) in the process entry point causes prototype-pollution attempts to fail loudly (in strict mode) or silently no-op, providing defense in depth even against future or unknown gadget chains. - Prefer
MaporObject.create(null)for structures that hold user-controlled keys, since these don't inherit fromObject.prototypeand can't be polluted through it. - Add automated SCA scanning to CI so that reintroducing a vulnerable lodash version — via a new dependency, a lockfile regression, or a Dockerfile that installs from a stale cache — fails the build rather than shipping.
- Audit for gadget chains, not just the presence of the vulnerable version. A patched lodash removes the specific entry point, but if your application already has other unrelated deep-merge or unsafe property-assignment code paths, upgrading lodash alone won't close those.
How Safeguard Helps
Generic dependency scanners will flag "lodash 4.17.4 is present" on every service in your inventory, but that tells a security team nothing about which of those services actually calls merge, defaultsDeep, or zipObjectDeep with attacker-reachable input — the distinction between a theoretical CVE and an exploitable one. Safeguard's reachability analysis traces call paths from network- and user-facing entry points down into vulnerable lodash functions, so teams can triage the handful of genuinely exploitable instances instead of chasing every match across a fleet. Griffin AI, Safeguard's reasoning engine, goes further by inspecting the surrounding code to judge whether polluted properties actually influence a security-relevant decision downstream, cutting through the CVSS-versus-EPSS noise with an application-specific exploitability verdict. Continuous SBOM generation and ingestion keep the picture current across every transitive lodash.* submodule and vendored copy, closing the visibility gap that lets old versions hide three layers deep in a dependency tree. And where remediation is confirmed safe, Safeguard opens auto-fix pull requests that bump lodash to 4.17.21+ (with overrides pinned for stubborn transitive dependencies), so the fix ships without a manual dependency-chasing exercise.