FlawOpen

flawopen.com/command-injection/Javascript

● CWE-78 · Critical
FlawOpen Security Research

OS Command Injection in JavaScript (child_process, Node.js)

child_process.exec() with a template literal, execFile() with shell: true, or a user value that starts with a dash lets a request run extra commands on your Node.js server (CWE-78). The safe execFile/spawn call, the Windows batch-file trap behind CVE-2024-27980, and the lint rules that catch all three.

💡 Plain English Explainer (ELI5)

Think of a rail dispatcher receiving a destination. child_process.exec() sends a whole sentence to a shell, which treats punctuation such as ;, | and && as new instructions. execFile() and spawn() pass an argument array directly, so punctuation stays inside that one argument. Setting shell: true joins it back into shell input. Windows .bat and .cmd files are a special boundary too: cmd.exe reparses arguments, the issue tracked as CVE-2024-27980. Fix the executable, validate each field, keep shell disabled, and avoid batch files for user-controlled arguments.

Core Concepts & Subsystem Terms

child_process.exec()
Runs its whole first argument through /bin/sh -c (or cmd.exe on Windows). A template literal such as ` ping -c 1 ${host} puts user text where the shell reads ;, |, && and $( )`.
execFile() / spawn() with an argument array
execFile(file, args) and spawn(command, args) start the program directly, without a shell. Each array element is passed as exactly one argument, so shell metacharacters inside it are ordinary text.
The shell: true option
Passing { shell: true } to execFile or spawn joins the arguments into one command line and hands it to a shell again, bringing back the injection the array was meant to prevent. Leave it off.
Windows .bat / .cmd files (CVE-2024-27980)
Windows cannot execute .bat or .cmd files directly; it starts cmd.exe, which parses the arguments with its own rules. Before the April 2024 releases, spawn('run.bat', [userInput]) was injectable without shell: true. Patched Node.js throws EINVAL instead.
Argument injection and Git option boundaries
Separate argv entries prevent shell parsing, but the target program still parses its own options. For an untrusted Git revision, use git rev-parse --verify --end-of-options "$revision": this ends option parsing. -- has command-specific operand or path semantics; in git log, it separates revisions from paths. These markers are not interchangeable. Validate that the value is a revision before use.

Framework mappings

Step-by-Step Attack Flow

Step 1

Request value reaches the helper

An Express route passes req.query.host to ping() or a directory name from the request body to archive().

Step 2

A template literal builds a command line

` ping -c 1 ${host} is evaluated first. With the benign test value 127.0.0.1; id, the command line becomes ping -c 1 127.0.0.1; id`.

Step 3

The shell splits it into two commands

exec hands the string to /bin/sh -c, which runs ping and then id as the Node.js process user. With shell: true, execFile does the same; without a shell, an untrusted argument such as Git’s --output=<file> can still be parsed as an option.

Step 4

The attacker runs code on the server

The injected command inherits the process environment: API keys in process.env, cloud credentials, the network position of the server and write access to its files.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
// tools.js: each helper below lets request text reach a command line
import { exec, execFile } from 'node:child_process';
import { promisify } from 'node:util';

const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);

// exec runs the whole template literal through /bin/sh
export async function ping(host) {
  const { stdout } = await execAsync(`ping -c 1 ${host}`);
  return stdout;
}

// shell: true joins the array back into a shell command line
export async function archive(dir) {
  const { stdout } = await execFileAsync('tar', ['-czf', 'backup.tgz', dir], { shell: true });
  return stdout;
}

// no shell, but a ref starting with - is read as a Git option
export async function commitLog(ref) {
  const { stdout } = await execFileAsync('git', ['log', ref]);
  return stdout;
}
HARDENED SECURE PATCH
// tools.js: argument arrays, strict patterns and end-of-options markers
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const execFileAsync = promisify(execFile);
const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$/;
const DIR_RE = /^[A-Za-z0-9_-]{1,64}$/;

// execFile starts ping directly; host is one argument
export async function ping(host) {
  if (!HOST_RE.test(host)) throw new Error('invalid host name');
  const { stdout } = await execFileAsync('ping', ['-c', '1', host], { timeout: 5000 });
  return stdout;
}

// no shell option, and -- ends tar's option parsing
export async function archive(dir) {
  if (!DIR_RE.test(dir)) throw new Error('invalid directory name');
  const { stdout } = await execFileAsync('tar', ['-czf', 'backup.tgz', '--', dir], { timeout: 30000 });
  return stdout;
}

// --end-of-options makes Git treat ref as a revision
export async function commitLog(ref) {
  const { stdout } = await execFileAsync('git', ['log', '--end-of-options', ref, '--'], { timeout: 5000 });
  return stdout;
}

Engineering & System Hardening Checklist

Sources