This is a loaded nail gun left in the workshop, not a sniper rifle pointed at the internet
CVE-2026-4800 is a code-injection flaw in Lodash _.template. Affected ranges are lodash, lodash-es, and lodash-amd >=4.0.0 through <=4.17.23, plus lodash.template >=4.0.0 and <4.18.0; the fix lands in 4.18.0. The bug exists because the old hardening for CVE-2021-23337 validated the variable option but did not validate options.imports key names, even though both paths feed the same Function() constructor sink.
Vendor HIGH is technically defensible for reachable cases, but it overstates broad enterprise risk. This is not a generic internet-reachable daemon bug; exploitation usually requires a very specific developer mistake—passing attacker-controlled keys into _.template options—or a chain with separate prototype pollution. For a 10,000-host fleet, that turns this from an emergency patch-everything event into a targeted hunt for reachable usage.
4 steps from start to impact.
Find a reachable _.template sink
_.template with a controllable second argument, especially options.imports, or a code path where Object.prototype can already be polluted. The vulnerable behavior lives in library code, but reachability is entirely application-dependent. Weaponization starts with ordinary HTTP requests plus code-awareness, not mass scanning.- Target uses vulnerable Lodash package/version
- Application actually calls
_.template - Attacker can influence
options.importskeys or earlier prototype state
- Most apps use Lodash helpers without ever exposing
_.templateto user input - Many modern JS stacks do not use runtime template compilation at all
- This often requires source-level understanding or a second bug
npm audit, Dependabot, Trivy, Grype, osv-scanner) will flag vulnerable versions, but they cannot prove exploit reachability. Code search and taint analysis are the real detectors here.Inject a malicious imports key
threalwinky/CVE-2026-4800-POC, the attacker supplies a crafted imports key name that abuses default-parameter syntax so the generated function body becomes executable JavaScript. The alternate path is to pollute Object.prototype and let Lodash copy inherited properties into imports through the old merge behavior.- Attacker controls
importskey names or can polluteObject.prototypefirst - Application does not sanitize or freeze that path before calling
_.template
- Key-name injection is a niche coding anti-pattern
- Prototype-pollution chaining assumes another exploitable flaw already exists
- Input validation, schema enforcement, or use of static imports blocks this step
_.template(..., userControlledOptions) patterns. Runtime telemetry may show suspicious payload strings in request logs, but coverage is inconsistent.Trigger compile-time execution in Node.js
Function(). That turns the payload into code execution at template compilation time, running with the privileges of the application process. In server-side Node.js deployments, that is immediate app-context RCE.- Compilation path executes on the server
- Process has enough privileges to matter
- Some uses are client-side only, reducing impact to browser-side script execution
- Sandboxed runtimes, containers, seccomp, or reduced OS privileges can narrow blast radius
Post-exploitation pivot
child_process.exec*, credential theft from environment variables, or application data access. The blast radius is bounded by the app account, container permissions, mounted secrets, and network reach from that service.- Successful code execution in a meaningful service context
- Valuable secrets, data, or lateral paths available from the process
- Least-privilege service accounts and locked-down containers limit follow-on damage
- Outbound egress controls and secret managers can blunt easy monetization
The supporting signals.
| In-the-wild status | No authoritative public evidence of active exploitation found in the sources reviewed. This CVE is not in CISA KEV. |
|---|---|
| Public PoC availability | Yes. Public GitHub PoC exists at threalwinky/CVE-2026-4800-POC, which demonstrates prototype-pollution-assisted execution through _.template. |
| EPSS | 0.00044 from the user-provided intel; that is a very low short-term exploitation probability. FIRST's public EPSS page confirms score semantics, though the percentile was not directly exposed in this browsing workflow. |
| KEV status | Not listed in the CISA Known Exploited Vulnerabilities Catalog. |
| CVSS reality check | CNA/OpenJS scores it 8.1 HIGH with AC:H, which fits the prerequisite-heavy path. NVD later enriched it to 9.8 CRITICAL, but that drops the real-world friction and is too aggressive for fleet prioritization. |
| Affected versions | lodash, lodash-es, lodash-amd: >=4.0.0 to <=4.17.23; lodash.template: >=4.0.0 and <4.18.0. |
| Fixed versions | Primary upstream fix is 4.18.0. Be careful with distro packages: Ubuntu's node-lodash page still showed Needs evaluation across supported releases when checked, so distro consumers should verify vendor backports rather than only npm upstream versions. |
| Exposure/scanning reality | This is not directly internet-fingerprintable via Shodan/Censys/GreyNoise because Lodash is an embedded library, not a network service. Exposure has to be derived from SBOM/SCA plus code search for _.template and options.imports usage. |
| Disclosure timeline | OpenJS/NVD show disclosure on 2026-03-31; OSV shows publication on 2026-04-01T23:51:12Z. |
| Reporter / source | GitHub advisory was published by Ulises Gascón; CNA source on NVD is openjs. |
noisgate verdict.
The decisive downward pressure is reachability: attackers do not get internet-wide RCE just because a host has Lodash installed; they need a specific app pattern that passes untrusted keys into _.template internals or a second bug to pollute prototypes first. That sharply narrows exposed population even though the technical impact, once triggered, is real code execution in the application process.
Why this verdict
- Reachability is the whole game: starting from the vendor's
8.1, I subtract because most enterprises have Lodash everywhere but do not expose attacker-controlledoptions.importsinto_.template. - The alternate exploit path assumes prior compromise of app logic: the prototype-pollution route is a chain, not a clean standalone remote bug. Requiring a second vulnerability or dangerous coding pattern compounds downward pressure.
- Threat intel is cold: no KEV, no strong public in-the-wild reporting in reviewed sources, and an EPSS of
0.00044all argue against treating this like an immediate fleet emergency.
Why not higher?
If this were a default-on network service flaw or broadly reachable request-to-RCE in typical Node stacks, it would stay HIGH or go higher. But it is a library sink gated by uncommon application behavior, and that means the exposed population is much smaller than the package install base.
Why not lower?
I am not pushing this to LOW because successful exploitation is still real code execution with no authentication once the vulnerable application path exists. Internet-facing apps that compile templates from tainted input can go from bug to shell quickly, so this deserves targeted investigation rather than backlog oblivion.
What to do — in priority order.
- Inventory reachable
_.templateusage — Use code search, Semgrep, or taint analysis to find_.template(calls and whether the second argument can be influenced by request data. For aMEDIUMverdict there is no mitigation SLA, so do this in the next normal engineering cycle and use it to separate harmless transitive presence from real exposure. - Block untrusted
importskeys — If immediate upgrade is not possible, enforce developer-controlled static keys foroptions.importsand reject any request path that maps attacker input into template engine configuration. There is no mitigation SLA forMEDIUM; fold this into the normal remediation plan where reachable usage exists. - Kill the prototype-pollution chain — Patch or disable adjacent prototype-pollution sources that can taint
Object.prototype, because this CVE explicitly becomes easier when polluted keys are inherited into template imports. Again, there is no mitigation SLA here; handle it as part of the application hardening workstream. - Constrain Node process blast radius — Run services as non-root, restrict container capabilities, minimize mounted secrets, and tighten egress so app-context RCE does not become instant host or cloud compromise. This does not remove the bug, but it materially reduces impact while you move through the normal patch window.
- A generic WAF does not solve this; the payload is meaningful only when your application feeds it into
_.template, and many request shapes will look like ordinary JSON. - Version-only asset scanning is insufficient; it tells you where Lodash exists, not whether
_.templateis reachable from untrusted input. - Relying on EDR alone is too late; it may catch child-process behavior after code execution, but it will not reliably prevent the dangerous template compilation path.
Crowdsourced verification payload.
Run this on the application source tree, build workspace, or target host where the Node.js app and its dependency files live. Invoke it with python3 check_cve_2026_4800.py /path/to/app; no admin rights are required, but the script needs read access to node_modules and common lockfiles.
#!/usr/bin/env python3
# CVE-2026-4800 verifier for Lodash packages
# Usage: python3 check_cve_2026_4800.py /path/to/app
# Exit codes: 0=PATCHED, 1=VULNERABLE, 2=UNKNOWN
import json
import os
import re
import sys
from typing import Optional, Tuple, List
TARGETS = {
'lodash': (4, 18, 0),
'lodash-es': (4, 18, 0),
'lodash-amd': (4, 18, 0),
'lodash.template': (4, 18, 0),
}
Version = Tuple[int, int, int]
def parse_version(raw: str) -> Optional[Version]:
if not raw:
return None
m = re.search(r'(\d+)\.(\d+)\.(\d+)', raw)
if not m:
return None
return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
def is_vulnerable(pkg: str, ver: Version) -> bool:
fixed = TARGETS[pkg]
return ver >= (4, 0, 0) and ver < fixed
def load_json(path: str):
try:
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception:
return None
def check_node_modules(root: str) -> List[str]:
findings = []
nm = os.path.join(root, 'node_modules')
if not os.path.isdir(nm):
return findings
for pkg in TARGETS:
pkg_json = os.path.join(nm, pkg, 'package.json')
data = load_json(pkg_json)
if not data:
continue
ver = parse_version(str(data.get('version', '')))
if not ver:
findings.append(f'UNKNOWN {pkg} version unreadable in node_modules')
continue
if is_vulnerable(pkg, ver):
findings.append(f'VULNERABLE {pkg} {data.get("version")} via node_modules')
else:
findings.append(f'PATCHED {pkg} {data.get("version")} via node_modules')
return findings
def walk_pkg_lock_packages(packages_obj) -> List[str]:
findings = []
if not isinstance(packages_obj, dict):
return findings
for path_key, meta in packages_obj.items():
if not isinstance(meta, dict):
continue
name = meta.get('name')
if not name and path_key.startswith('node_modules/'):
name = path_key.split('node_modules/', 1)[1]
if name not in TARGETS:
continue
ver = parse_version(str(meta.get('version', '')))
if not ver:
findings.append(f'UNKNOWN {name} version unreadable in package-lock packages')
continue
if is_vulnerable(name, ver):
findings.append(f'VULNERABLE {name} {meta.get("version")} via package-lock packages')
else:
findings.append(f'PATCHED {name} {meta.get("version")} via package-lock packages')
return findings
def walk_dependency_tree(deps) -> List[str]:
findings = []
if not isinstance(deps, dict):
return findings
stack = list(deps.items())
while stack:
name, meta = stack.pop()
if not isinstance(meta, dict):
continue
if name in TARGETS:
ver = parse_version(str(meta.get('version', '')))
if not ver:
findings.append(f'UNKNOWN {name} version unreadable in dependency tree')
elif is_vulnerable(name, ver):
findings.append(f'VULNERABLE {name} {meta.get("version")} via dependency tree')
else:
findings.append(f'PATCHED {name} {meta.get("version")} via dependency tree')
child = meta.get('dependencies')
if isinstance(child, dict):
stack.extend(child.items())
return findings
def check_package_lock(root: str) -> List[str]:
findings = []
for filename in ('package-lock.json', 'npm-shrinkwrap.json'):
path = os.path.join(root, filename)
data = load_json(path)
if not data:
continue
findings.extend(walk_pkg_lock_packages(data.get('packages')))
findings.extend(walk_dependency_tree(data.get('dependencies')))
return findings
def dedupe(findings: List[str]) -> List[str]:
seen = set()
out = []
for item in findings:
if item not in seen:
seen.add(item)
out.append(item)
return out
def main() -> int:
if len(sys.argv) != 2:
print('UNKNOWN - usage: python3 check_cve_2026_4800.py /path/to/app')
return 2
root = sys.argv[1]
if not os.path.isdir(root):
print(f'UNKNOWN - path not found: {root}')
return 2
findings = []
findings.extend(check_node_modules(root))
findings.extend(check_package_lock(root))
findings = dedupe(findings)
if not findings:
print('UNKNOWN - no target packages found in node_modules or npm lockfiles')
return 2
vuln = [f for f in findings if f.startswith('VULNERABLE ')]
patched = [f for f in findings if f.startswith('PATCHED ')]
if vuln:
print('VULNERABLE - ' + '; '.join(vuln))
return 1
if patched:
print('PATCHED - ' + '; '.join(patched))
return 0
print('UNKNOWN - ' + '; '.join(findings))
return 2
if __name__ == '__main__':
sys.exit(main())
If you remember one thing.
_.template with tainted options usage in internet-facing Node services first. For a MEDIUM verdict, the noisgate mitigation SLA is no mitigation SLA — go straight to the 365-day remediation window, and the noisgate remediation SLA is to upgrade reachable and remaining vulnerable packages to >=4.18.0 within 365 days; if you discover an actually reachable untrusted-template path, accelerate that application outside the generic fleet clock.Sources
What defenders are saying.
Crowdsourced verification outputs.
Results submitted by users who ran the verification payload against their environment.