CVE-2026-27601 & CVE-2021-23358: Underscore.js DoS and Template Code Injection
How two unguarded inputs to Underscore.js internals, an unbounded recursion depth, and an unvalidated template variable name, let remote data crash a Node.js process or run attacker-chosen code.

CVE-2026-27601 and CVE-2021-23358 are two open vulnerabilities in Underscore.js, a JavaScript utility library still pulling roughly 23 million npm downloads a week.
CVE-2026-27601, published by the GitHub CNA on March 3, 2026 and credited to ByamB4, is rated High at CVSS 4.0 8.2 (CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N) by the CNA and Medium at CVSS 3.1 5.9 (CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H) by NVD. Unbounded recursion (CWE-770) in _.flatten and _.isEqual lets deeply nested input overflow the call stack. It affects every release through v1.13.7 and is fixed in v1.13.8.
CVE-2021-23358, assigned by Snyk and published to NVD on March 29, 2021, is rated High at CVSS 3.1 7.2 by NVD (CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H) and Critical at 9.8 in the GitHub Advisory Database. An unvalidated variable setting is concatenated into a new Function declaration (CWE-94), so whoever controls that name controls executed code. It affects >= 1.3.2, <= 1.12.0 , the 1.13.0-0 and 1.13.0-1 prereleases, and is fixed in 1.12.1 and 1.13.0-2.
Running Underscore.js in a stack you cannot upgrade? Start with the HeroDevs Vulnerability Directory for affected-version detail, and see Never-Ending Support for the frameworks around it.
What are these CVEs?
Both bugs live in Underscore's internals rather than in application code, and both come from the same omission: a value that arrives from outside is used without a bound or a validity check.
CVE-2026-27601: unbounded recursion in _.flatten and _.isEqual
Classified CWE-770 (Allocation of Resources Without Limits or Throttling), this affects the internal flatten() helper in modules/_flatten.js and the eq() / deepEq() pair behind modules/isEqual.js. Through 1.13.7 both walked nested structures by calling themselves once per nesting level. Because JavaScript engines cap call-stack depth, a structure nested deeply enough exhausts the stack and throws RangeError: Maximum call stack size exceeded. Underscore does not catch that error, so it propagates into the caller.
Two preconditions matter. Untrusted input has to build the nested structure, typically through JSON.parse with no depth limit, and that structure has to reach one of the two functions. For _.flatten, the value must be arrays at every level and the call must omit the optional second depth argument. For _.isEqual, there must be a code path where two distinct but structurally equivalent values from the same source are compared, which is what happens when a client submits data that gets persisted and then submits an equivalent copy that is diffed against the stored record.
CVE-2021-23358: arbitrary code injection through _.template
Classified CWE-94 (Improper Control of Generation of Code), this affects modules/template.js. Underscore compiles a template by building a JavaScript source string and handing it to new Function. The name of the data argument comes from settings.variable (or _.templateSettings.variable), and through 1.12.0 that string was interpolated into the function declaration with no validation. A value that is not a plain identifier escapes the parameter position and becomes executable code inside the compiled render function. The result runs with the privileges of whatever is doing the rendering.
Severity and exploit conditions
CVE-2026-27601 (NVD primary, CVSS 3.1 base 5.9, Medium)
The GitHub CNA scored the same issue 8.2 High under CVSS 4.0, where AT:P (Attack Requirements: Present) carries the same "specific conditions must hold" caveat that NVD expressed as AC:H. The score gap is a versioning and weighting difference, not a factual disagreement: both scorers treat this as remote, unauthenticated, availability-only.
CVE-2021-23358 (NVD primary, CVSS 3.1 base 7.2, High)
Scores for this CVE diverge across sources, and the divergence is worth understanding before you triage it. NVD, the primary scorer, gives 7.2 High with PR:H. The GitHub Advisory Database gives 9.8 Critical with PR:N, treating the variable name as attacker-reachable by default. Snyk, the assigning CNA, gives 3.3 Low with AC:H and PR:H, treating it as hard to reach. The whole spread turns on one question about your codebase: can any value that an outside party influences end up as the variable option or as _.templateSettings.variable? If yes, treat this as critical. If the option is a hardcoded literal, the practical exposure is closer to Snyk's read. Grep for it before you decide.
Exploitation status
CVE-2026-27601. A public proof of concept exists, and it is in the upstream advisory itself. The GitHub Security Advisory publishes two short reproductions, one for _.isEqual and one for _.flatten, that produce RangeError: Maximum call stack size exceeded at roughly 4,500 levels of nesting. The CVE is not listed in the CISA Known Exploited Vulnerabilities catalog, and no active exploitation has been reported. It has propagated into distribution advisories, including openSUSE-SU-2026:10424-1, 10427-1, 10440-1, and 21448-1, which is how it tends to surface in scanner output.
CVE-2021-23358. No weaponized exploit is published in the advisory, and the CVE is not in the CISA KEV catalog. The mechanism is fully documented in the upstream source, and the fix commit makes the injection point explicit, so the barrier to reproducing it is knowledge of the codebase rather than of the vulnerability. Five years on it still appears constantly in scan results because it flowed downstream into Debian DSA-4883, Fedora package announcements, Tenable TNS-2021-14, NetApp advisories, and Apache Cordova issue threads, all of which keep the CVE attached to old bundled copies of the library.
Root cause: what the fixes actually changed
Both fixes are worth reading because they tell you exactly what to look for in a vendored or patched copy.
For CVE-2026-27601, Underscore 1.13.8 removed recursion from both code paths. The internal flatten helper went from a function that calls itself per level to one that trampolines on an explicit stack:
// 1.13.7, modules/_flatten.js
export default function flatten(input, depth, strict, output) {
// ...
if (depth > 1) {
flatten(value, depth - 1, strict, output);
// 1.13.8, modules/_flatten.js
export default function flatten(input, depth, strict) {
// We will be avoiding recursive calls because this could be exploited to
// cause a stack overflow (CVE-2026-27601). Instead, we "trampoline" on an
// explicit stack.
var output = [], idx = 0, i = 0, length = getLength(input) || 0, stack = [];
isEqual received the same treatment. In 1.13.7 it delegated to recursive eq() and deepEq() helpers that threaded aStack and bStack through each call. In 1.13.8 the comparison is driven by a todo array of pending pairs inside a while loop, so nesting depth costs heap rather than stack.
For CVE-2021-23358, the fix in 1.12.1 added a guard before the variable name reaches new Function:
// modules/template.js
var bareIdentifier = /^\s*(\w|\$)+\s*$/;
var argument = settings.variable;
if (argument) {
// Insure against third-party code injection. (CVE-2021-23358)
if (!bareIdentifier.test(argument)) throw new Error(
'variable is not a bare identifier: ' + argument
);
}
If a vendored copy of template.js in your tree does not contain bareIdentifier, that copy is vulnerable regardless of what your lockfile says.
What an attacker can do
- Crash a request handler through _.flatten. POST a JSON body of several thousand nested arrays to any endpoint that parses it and calls _.flatten(parsed) without a second depth argument. The resulting RangeError is not caught by Underscore.
- Take down the process. In a single-threaded Node.js service, if that RangeError escapes an async boundary and lands as an uncaught exception or an unhandled rejection, the process exits and every in-flight connection on it dies with the request. One cheap request, full worker restart, repeatable.
- Reach the crash from a read path through _.isEqual. Submit a deeply nested object that gets persisted, then submit an equivalent copy so a later diff, cache-validation, or idempotency check compares the two with _.isEqual. The crash then fires on retrieval or on queued background work, not on the write, which is harder to attribute and often outside request-level input validation.
- Execute arbitrary code through _.template. Where an application derives the variable option or _.templateSettings.variable from configuration, a query parameter, a tenant record, or any other outside-influenced value, a value that is not a bare identifier lands inside the new Function declaration and runs. Server-side means full Node.js capability in the rendering process, including require('child_process'), filesystem access, and environment variables holding credentials. Client-side it means script execution in the page origin, with access to cookies and localStorage.
Who is affected?
Both fixes are freely available in open source, which is the single most important fact for triage here. Underscore has no published end-of-life date, but its release cadence is slow: 1.13.7 shipped in July 2024 and 1.13.8 did not follow until February 19, 2026, a nineteen-month gap. Plan on the assumption that the next fix will not be fast either.
Note that the version in your lockfile is not necessarily the version in your bundle. Underscore is commonly a transitive dependency and is also commonly vendored as a file. Backbone 1.6.1, for example, declares underscore: ">=1.8.3", an unpinned range, so a stale lockfile can hold you on an affected version indefinitely while the declared dependency looks satisfied.
Mitigation guidance
Pinning a transitive copy with npm overrides:
{
"overrides": {
"underscore": "1.13.8"
}
}
Yarn uses resolutions and pnpm uses pnpm.overrides for the same effect. After applying either, re-run the install and verify with npm ls underscore that only 1.13.8 appears.
Related CVEs
Underscore is one instance of a recurring pattern: a small, ubiquitous JavaScript utility library that walks or transforms untrusted structures without a bound. These entries in the HeroDevs Vulnerability Directory cover the same shapes.
- CVE-2026-21717: HashDoS in the V8 engine used by Node.js, where request-controlled input drives unbounded work in a runtime primitive. See also the deep dive on CVE-2026-21717 in Node.js.
- CVE-2024-21536: Denial of service in http-proxy-middleware, another small npm dependency whose crash takes the parent process with it.
- CVE-2019-11358: Prototype pollution through jQuery.extend(true, {}, ...), a utility-library deep-merge that trusted the shape of its input.
- CVE-2021-23450: Prototype pollution in Dojo's setObject, the same failure in a different utility library.
For the wider pattern of unbounded work driven by remote input, see What are ReDoS attacks. For the specific problem of patching a ubiquitous legacy frontend library without rewriting the application around it, see how to patch jQuery vulnerabilities in production.
Taking action
Both of these have free upstream fixes, so the decision is not whether to patch but how far the patch actually reaches. Move Underscore to 1.13.8, then verify the result rather than trusting the manifest: run npm ls underscore to catch a transitive copy held back by an unpinned range, and grep the repository for vendored template.js and _flatten.js files that no package manager will ever update. Where you cannot move the version yet, bound every _.flatten call on untrusted data and make certain no outside-influenced value can reach the variable template option.
The harder question is the stack around the library. Underscore turns up most often in codebases built on frameworks that stopped receiving updates years ago, and in those codebases the utility library is rarely the oldest thing in the tree. If the framework under your Underscore dependency is past end of life, patching one npm package leaves the larger exposure in place. HeroDevs Never-Ending Support provides drop-in replacements that resolve vulnerabilities in end-of-life frameworks, so teams that cannot migrate still get a supported, non-vulnerable stack.
Primary sources: GHSA-qpx9-hpmf-5gmw and NVD: CVE-2026-27601; GHSA-cf4h-3jhx-xvhq and NVD: CVE-2021-23358.
Resources
View All Articles
.png)

