Node.js backdoor detection: eval, exec, extra listeners

A teal crate with an extra coral listening horn, house style.

Node.js 20 Iron went end of life on 24 March 2026. I opened the Node releases page on 22 August 2026: 24 Krypton is LTS, last updated 3 August 2026, and the 24.19.0 vm page still opens with: the node:vm module is not a security mechanism. Do not use it to run untrusted code.This page is how you detect an evaluator, a spawned shell, and a bind you did not mean.

The longer Express story lives on JavaScript as a backend: merge, user URLs, eval, leaky 500s. Keep that page next to this one. Read Helmet for the header pack, malicious dependencies when the hatch arrived in a wheel, and injection when the string reaches SQL or a shell. This page is the three shapes that turn a process into a second interpreter.

Node 20 is dead. 24 still trusts every file it loads

The releases table I opened lists v20 Iron as EOL on 24 March 2026, v22 Jod still in LTS, v24 Krypton in LTS since 6 May 2025, and v26 Current since 5 May 2026. Production should sit on 22 or 24. A 20.x binary after March is a backport contract, not an OpenJS patch.

The current permission docs say the same threat model the project has always had: Node trusts any code it is asked to run. A require of a package you did not read is that trust. On 8 and 9 September 2025 that trust failed in public.

CWE-94 is eval or new Function on posted text. CWE-78 is child_process.exec on a string the client touched. A second listen is not a CWE with a famous number. It is a process that grew a port you did not put in the runbook. All three are inventory problems first.

ShapeFirst-party noteWhat you keep
eval / Functionlanguage evala parser you wrote
node:vmdocs: do not sandbox with itdo not run posted text
child_process.execnever pass unsanitized inputrunTool via execFile
extra listenyour bind helperbindApp only

eval and vm are not a sandbox

I opened that vm page the same day. The warning is the second paragraph. A different V8 context is still your process. It can still reach process, require, and every handle you left on the object you contextified. vm.runInNewContext on a request body is CWE-94 with extra steps.

The same is true of eval and new Function. Express 5.2.1, still latest when I opened npm on 22 August 2026, does not change that. Your route does. If the client sent JSON, parse JSON. If the client sent a formula, write a grammar. Do not compile the string.

function parseConfig(text) {
  const data = JSON.parse(text);
  if (data === null || typeof data !== "object" || Array.isArray(data)) {
    throw new Error("config must be an object");
  }
  return data;
}

parseConfig is the named fallback. A settings POST becomes an object you can print in a ticket. The miss is the one-liner people still paste:

// BAD: do not ship
// const cfg = eval("(" + req.body + ")");
// const fn = new Function("return (" + req.body + ")");
// vm.runInNewContext(req.body, Object.create(null));

A later JSON.parse that you then eval for “expressions” is the same hatch. Keep expressions in a library that cannot see process. The control is: posted text never reaches eval, Function, or vm.

execFile, not exec, and never a shell string

The child_process page draws the line in the first list. exec spawns a shell and runs a command string inside it. execFile spawns the file directly. The exec section then says: never pass unsanitized user input to this function. Any input containing shell metacharacters may be used to trigger arbitrary command execution. That sentence is the whole reason this section exists.

v23.11.0 and v22.15.0 deprecated passing args when shell is true. DEP0190 is the sibling warning on spawn with shell. Leave the shell off.

const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const path = require("node:path");

const execFileAsync = promisify(execFile);
const CONVERT = "/usr/bin/convert";
const INBOX = "/var/app/inbox";

function runTool(bin, argv) {
  if (bin !== CONVERT) {
    return Promise.reject(new Error("bin not listed"));
  }
  const safe = argv.map((item) => {
    const resolved = path.resolve(INBOX, item);
    if (!resolved.startsWith(INBOX + path.sep) && resolved !== INBOX) {
      throw new Error("path leaves inbox");
    }
    return resolved;
  });
  return execFileAsync(bin, safe, { timeout: 8000, windowsHide: true });
}

runTool is the named helper. The binary is a constant. Arguments resolve inside INBOX. There is no shell: true. The miss is exec(`convert ${file}`) or execFile(bin, argv, { shell: true }).

On Windows, .bat and .cmd are not executable without a terminal. The docs say do not launch them through execFile and then flip shell on. Call the real executable by absolute path, or do the work in JavaScript.

Unexpected listeners are a second process

An HTTP server you meant is one listen. A second bind on 0.0.0.0, a random port, or process.on("message") that then opens a socket is a process you are no longer describing in the runbook. Detection is a wrap around bind, plus a grep, plus a runtime list of handles.

bindApp is the named helper. The port comes from the environment. The host is loopback unless you have a proxy in front that needs a LAN address you listed. Anything else throws.

function bindApp(app, env) {
  const port = Number(env.APP_PORT);
  const host = env.APP_HOST || "127.0.0.1";
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
    throw new Error("APP_PORT missing");
  }
  if (host !== "127.0.0.1" && host !== env.PROXY_BIND) {
    throw new Error("unexpected listen host");
  }
  return app.listen(port, host);
}

function listServers(handles) {
  return handles.filter((h) => h && typeof h.address === "function")
    .map((h) => h.address())
    .filter(Boolean);
}

Call bindApp(app, process.env) from the entry file. Do not call app.listen anywhere else. listServers is the probe you run after boot. Pass process._getActiveHandles() only if you already accept that function as undocumented. I opened the v24 API index and did not find it as a supported method. Prefer an explicit registry you push into from bindApp.

const REGISTRY = [];

function bindAppTracked(app, env) {
  const server = bindApp(app, env);
  REGISTRY.push(server);
  return server;
}

function unexpectedListen() {
  return REGISTRY.length !== 1;
}

bindAppTracked and unexpectedListen are the named pair. A test that boots the app and asserts REGISTRY.length === 1 is the five-minute proof. A second server in a dependency is a review. So is net.createServer().listen(0) in a helper you copied.

One listen you named. A child with a list. Posted text stays JSON.
BIND    bindApp(app, env)
        APP_PORT only
        host is loopback or PROXY_BIND

CHILD   runTool(CONVERT, argv)
        execFile, no shell
        paths stay in INBOX

DATA    parseConfig(text)
        JSON.parse only
        no eval, no Function, no vm

The permission model is a seat belt

I opened the v24 permissions page. The model left experimental in v23.5.0 and v22.13.0. Stability is 2. The flag is --permission. With it on, filesystem, child process, worker threads, native addons, WASI, and the inspector start denied. You opt back in with --allow-fs-read, --allow-fs-write, --allow-child-process, --allow-worker, --allow-addons, and --allow-wasi.

A worker that never converts files should omit --allow-child-process. Then runTool fails closed if someone pastes exec later. A process that only reads ./ and talks to one host should not pass --allow-fs-read=*. Wildcards after the first * are ignored. /home/*.js behaves like /home/*. Be exact.

node --permission \
  --allow-fs-read=./ \
  --allow-fs-read=/var/app/inbox \
  --allow-fs-write=/var/app/outbox \
  server.js

Add --allow-child-process only on the job that calls runTool. Do not put it on the API process. The permission page also warns that process._debugProcess(pid) is not gated by the inspector scope. Separate OS users if you need that boundary. That is an operator control, not an application flag.

Prove the greps and the bind

You are not walking an implant. You are proving the tree has no evaluator, no shell string, and one bind.

rg -n "\\beval\\(|new Function|vm\\.(runInNewContext|runInContext|compileFunction)|child_process|\\.exec\\(|\\.execSync\\(|shell:\\s*true|\\.listen\\(" \
  --glob '!node_modules' --glob '!dist'

A hit on eval( or new Function is a review. A hit on child_process.exec or shell: true needs a rewrite to runTool. A hit on .listen( that is not bindApp needs a name. node_modules is noisy. Scan it in a separate job that diffs against the last lockfile, especially after a week like 8 September 2025.

node --check server.js
# Expect: empty stdout, exit 0

node -e "const { unexpectedListen, bindAppTracked } = require('./bind'); const app = { listen: (p,h) => ({ address: () => ({ port: p, address: h }) }) }; bindAppTracked(app, { APP_PORT: '8080' }); console.log(unexpectedListen())"
# Expect: false

The second probe uses a stub listen. It does not open a socket. If unexpectedListen is true after one bindAppTracked, the registry is wrong. Then boot your own app and confirm ss -tpn or Get-NetTCPConnection shows one port, the one in APP_PORT.

Pin the engine. A package.json engines.node of >=22 plus a lockfile you commit is the other half of the September 2025 lesson. npm ci in CI. Do not run npm install on the server and hope the resolve stays kind.

Questions we keep getting

Is vm.runInNewContext safer than eval?

No. The warning on that vm page is unchanged. A new context is still your process. Posted text stays in parseConfig or a grammar you wrote. Do not compile it.

Does –permission replace the greps?

No. The permission page says the model does not protect against malicious code. It stops trusted code from spawning a child or writing a path you did not grant. Keep the eval grep and the listen registry. Use the flag as the belt on top.

Can I keep exec if I quote the filename?

The exec docs say never pass unsanitized user input. Quoting is leftover work for a shell you do not need. runTool uses execFile and a resolved path inside INBOX. That is the replacement.

Guy Bar-Gil

Guy Bar-Gil / About Author

Guy is a product manager at WhiteSource, where we enable software development teams to integrate open source fearlessly and without compromising agility. Before WhiteSource, Guy worked for the IDF's intelligence division, where he spent time as a combat operator and project manager. Outside of work, you can find Guy reading (everything from fiction to physics), playing and watching sports, traveling the world, and spending time with friends and family. LinkedIn