flawopen.com/command-injection/Ruby
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.
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, andsystemdoes 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 toopenis therefore command injection (deprecated since Ruby 3.3, but still present in many apps). UseFile.readorFile.open.Open3.capture2 with separate arguments- When given more than one argument,
Open3.capture2,systemandspawnrun 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\Aand\zfor 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; ingit log, it separates revisions from paths. These markers are not interchangeable. Validate that the value is a revision before use.
Framework mappings
- CWE reference for the weakness described in this guide: CWE-78
- OWASP Top 10:2025 — A05:2025 Injection
Step-by-Step Attack Flow
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.
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.
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.
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
# 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
# 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
- ✓Run Brakeman in CI and fail on Command Injection warnings; enable the RuboCop
Security/Opencop to flagKernel#openandURI.openwith non-literal arguments. - ✓Call
Open3.capture2,systemorspawnwith the program and each argument as separate strings; never use backticks,%x()or a single interpolated string with request data. - ✓Read files with
File.readorFile.openafter resolving the path under a fixed directory; never pass request data toKernel#open. - ✓Anchor every validation regex with
\Aand\z, never^and$, and require a letter or digit as the first character. - ✓Reject option-like values by default. For an untrusted Git revision, call
git rev-parse --verify --end-of-options "$revision"; use--only with the operand or path meaning documented by that exact command.
Sources
- CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- Ruby docs — Open3
- Ruby docs — Command injection
- RuboCop — Security/Open
- Brakeman — Command Injection
- OWASP OS Command Injection Defense Cheat Sheet
- POSIX Utility Syntax Guidelines
- Git rev-parse — --end-of-options
- Git — Command-Line Interface Conventions
- Git — git log