FlawOpen

flawopen.com/command-injection/Ruby

● CWE-78 · Critical
FlawOpen Security Research

OS Command Injection in Ruby (Open3)

Backticks and system(string) run input through /bin/sh, Kernel#open starts a process when the name begins with |, and a ^...$ regex passes a dash-prefixed value as long as a later line looks valid, so request data in any of them can run code on your Ruby server (CWE-78). How each one fails and how Open3 with separate arguments, File.read and \A...\z fix it.

💡 Plain English Explainer (ELI5)

Imagine a Ruby archive where a name can select a folder, but a name beginning with | switches the clerk from opening a file to starting a process. Backticks and %x() always run text through a shell; system(string) does so when the string contains shell metacharacters. Open3.capture2 with separate arguments avoids shell parsing, but the call shape matters: one combined string can still invoke a shell. Ruby adds another trap: ^ and $ match line boundaries, so they do not validate the whole value; the guide’s allowlist also accepts option-like values such as --output=/tmp/log. Use \A and \z for whole-string checks. Validate the value, use File.read for files, and pass fixed programs and separate arguments to Open3.

Core Concepts & Subsystem Terms

Backticks, %x() and system(string)
Backticks and %x() always run the string through /bin/sh, and system does too when it gets one string that contains shell metacharacters. Interpolating request data with #{} lets it add commands.
Kernel#open with a leading |
open("|id") does not open a file: a name that starts with | runs the rest as a shell command and returns a pipe. Passing a request value to open is therefore command injection (deprecated since Ruby 3.3, but still present in many apps). Use File.read or File.open.
Open3.capture2 with separate arguments
When given more than one argument, Open3.capture2, system and spawn run the program directly with each value as one argument, and no shell is involved. A single string argument may still go through the shell.
\A and \z versus ^ and $
In Ruby, ^ and $ match at the start and end of any line, not the whole string. /^[\w.\/=-]+$/ accepts option-like values such as --output=/tmp/log; the line anchors also let a match cover just one line of a multiline value. Use \A and \z for whole-string checks, and reject leading dashes when validating a positional ref.
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

A Rails controller passes params[:host], params[:name] or params[:ref] to a helper module that starts a process or opens a file.

Step 2

The value becomes part of a command

host is interpolated into backticks, name starts with | and turns open into a process launch, or a multi-line ref passes the ^...$ check. The benign test value 127.0.0.1; id illustrates the shell case.

Step 3

The shell or the program interprets it

sh runs ping and then id; open runs the command after the pipe; Git reads the multi-line ref, which starts with --output=, as an option.

Step 4

The attacker runs code on the server

The process runs as the Puma or Unicorn user: it can read config/master.key, environment variables and database credentials, reach internal services and write files.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
# tools.rb: each method lets request data change what runs
require "open3"

module Tools
  module_function

  # backticks run the interpolated string through sh
  def ping(host)
    `ping -c 1 #{host}`
  end

  # a name starting with | makes open() run a command
  def read_report(name)
    open(name, &:read)
  end

  # ^ and $ match per line: only one line has to look valid
  def commit_log(ref)
    raise ArgumentError, "invalid ref" unless ref.match?(/^[\w.\/=-]+$/)
    out, _status = Open3.capture2("git", "log", ref)
    out
  end
end
HARDENED SECURE PATCH
# tools.rb: separate arguments, File.read, \A...\z and a time limit
require "open3"

module Tools
  module_function

  HOST = /\A[A-Za-z0-9][A-Za-z0-9.-]{0,252}\z/
  REF = %r{\A[A-Za-z0-9][A-Za-z0-9._/-]{0,99}\z}
  REPORT_DIR = File.expand_path("reports", __dir__)

  # several arguments: no shell, wrapped in a 5-second timeout
  def run(*command)
    out, status = Open3.capture2("timeout", "5", *command)
    raise "command failed: #{command.first}" unless status.success?
    out
  end

  # ping runs directly; host is one validated argument
  def ping(host)
    raise ArgumentError, "invalid host name" unless HOST.match?(host)
    run("ping", "-c", "1", host)
  end

  # File.read never starts a process; the path stays in REPORT_DIR
  def read_report(name)
    path = File.expand_path(name, REPORT_DIR)
    raise ArgumentError, "invalid report" unless path.start_with?(REPORT_DIR + File::SEPARATOR)
    File.read(path)
  end

  # \A...\z checks the whole string; --end-of-options marks the ref
  def commit_log(ref)
    raise ArgumentError, "invalid ref" unless REF.match?(ref)
    run("git", "log", "--end-of-options", ref, "--")
  end
end

Engineering & System Hardening Checklist

Sources