Back to Blog
high SEVERITY7 min read

How command injection happens in Ruby and how to fix it

A Fastlane helper used a Ruby backtick subshell to clone a plugin's git repository, interpolating `self.homepage` directly into a shell command string. Even with `shellescape` applied, the pattern was flagged as a dangerous subshell that could be chained into a command injection primitive; the fix replaces it with `system()` using an argument array, eliminating shell interpretation entirely.

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

Answer Summary

This is a Ruby command injection vulnerability (CWE-78) caused by using a backtick subshell (`` `...` ``) with interpolated, non-static input in `fastlane/helper/plugin_scores_helper.rb`. The fix replaces the backtick command with `system({ "GIT_TERMINAL_PROMPT" => "0" }, "git", "clone", self.homepage, clone_folder)`, which passes arguments directly to the OS without invoking a shell, removing the injection primitive entirely.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixReplaced the backtick subshell with `system()` using an explicit argument array, bypassing shell parsing entirely
riskArbitrary command execution if a malicious/unexpected value reaches the interpolated shell string
languageRuby
root causeInterpolating a variable (`self.homepage`) into a backtick (`` `...` ``) subshell command instead of invoking the executable directly
vulnerabilityCommand Injection via Ruby backtick subshell

Summary

A Fastlane plugin scoring helper cloned a plugin's git repository by shelling out with a backtick subshell that interpolated self.homepage — a value not guaranteed to be a static, trusted string. Even though the code called .shellescape on it, Semgrep flagged this as a dangerous subshell pattern (ruby.lang.security.dangerous-subshell.dangerous-subshell). The fix swaps the backtick command for system() with an explicit argument array, removing shell parsing from the equation entirely.

Introduction

The fastlane/helper/plugin_scores_helper.rb file is responsible for scoring Fastlane plugins, and part of that scoring process involves cloning each plugin's git repository to inspect its metadata. To do this, the append_git_data method built a shell command using Ruby's backtick syntax:

`GIT_TERMINAL_PROMPT=0 git clone #{self.homepage.shellescape} #{clone_folder.shellescape}`

At first glance this looks safe — shellescape is a well-known Ruby method for escaping shell metacharacters. But Semgrep's dangerous-subshell rule doesn't just look for unescaped interpolation; it flags any non-static value inside a backtick subshell, because backticks always invoke /bin/sh -c to parse and execute the resulting string. That means the security of the whole line depends entirely on shellescape correctly neutralizing every character self.homepage could ever contain — a fragile guarantee when homepage is metadata pulled from a plugin's gemspec, which is ultimately provided by third-party plugin authors.

This matters for any Ruby developer who reaches for backticks, %x{}, or system("string with #{interpolation}") to run external commands. The moment a shell is involved, you've introduced a parsing layer that can be abused — even through supposedly "escaped" input, edge cases, or environment quirks.

The Vulnerability Explained

Here's the vulnerable line, at fastlane/helper/plugin_scores_helper.rb:197:

def append_git_data
  Dir.mktmpdir("fastlane-plugin") do |tmp|
    clone_folder = File.join(tmp, self.name)
    `GIT_TERMINAL_PROMPT=0 git clone #{self.homepage.shellescape} #{clone_folder.shellescape}`

    break unless File.directory?(clone_folder)
    ...

The problem is structural, not just about escaping:

  1. Backticks always spawn a shell. `command` in Ruby is sugar for Kernel# (shell execution via /bin/sh -c "command"). Anything inside the backticks is first parsed by the shell, then executed.
  2. self.homepage is attacker-influenceable data. This value comes from a plugin's published gemspec metadata — data that is, by definition, supplied by third-party plugin authors, some of whom may be malicious or compromised. It's not a hardcoded, static string.
  3. shellescape is a mitigation, not an architectural fix. It's supposed to wrap the value in quotes and escape special characters, but relying on escaping logic to be perfect for every shell, locale, and edge case is exactly the kind of fragile defense that security reviewers (and automated scanners) flag as risky. If a future refactor accidentally drops the .shellescape call, or if there's a subtle escaping bug for characters like newlines or backticks-within-backticks, the protection silently disappears.

Example attack scenario: Imagine a malicious plugin author publishes a gem where the homepage field in the gemspec is crafted to look like a normal URL but contains shell metacharacters designed to break out of the escaped context (e.g., through encoding tricks, Unicode homoglyphs, or a bug in how shellescape handles certain byte sequences on the CI runner's shell). When plugin_scores_helper.rb runs during a scoring/scan job — potentially in CI, with access to secrets, network, and the filesystem — that crafted homepage value flows into the backtick subshell and could execute arbitrary commands on the machine running the scorer, rather than just cloning a repo.

Even if today's shellescape call successfully blocks a known exploit, Semgrep's rule exists because exploit-development tooling looks for exactly this primitive — non-static data reaching a shell invocation — and chains it with other weaknesses (encoding bugs, locale differences, environment variable injection via GIT_TERMINAL_PROMPT=0) to eventually get a working exploit.

The Fix

The PR replaces the backtick subshell with Ruby's system() method, called with an explicit argument array:

Before:

`GIT_TERMINAL_PROMPT=0 git clone #{self.homepage.shellescape} #{clone_folder.shellescape}`

After:

system({ "GIT_TERMINAL_PROMPT" => "0" }, "git", "clone", self.homepage, clone_folder)

This is a small diff, but the security model behind it is fundamentally different:

  • No shell is invoked. When system() is called with multiple arguments (rather than a single string), Ruby executes the command directly via execve-style semantics, passing each argument as a separate, literal string to the git binary. There is no /bin/sh -c step to parse metacharacters, so there's nothing for an attacker to "escape out of."
  • Environment variables are passed safely. The first argument, { "GIT_TERMINAL_PROMPT" => "0" }, sets the environment variable for the child process without needing to interpolate it into a command string — removing another potential injection surface.
  • shellescape is no longer needed — and that's the point. Because self.homepage and clone_folder are passed as discrete arguments, there's no escaping logic to get wrong. The fix removes an entire class of "did we escape this correctly?" bugs rather than trying to perfect the escaping.
  • Behavior is preserved. The command still runs git clone <homepage> <clone_folder> with the terminal prompt disabled, so legitimate plugin cloning continues to work exactly as before — only the mechanism for invoking git changed.

Prevention & Best Practices

  • Never use backticks, %x{}, or single-string system("...") with interpolated values. If you need to run an external command in Ruby, always prefer the array form: system("git", "clone", url, path), Open3.capture3("git", "clone", url, path), or Kernel.spawn with array arguments.
  • Treat external metadata as untrusted input. Values like homepage, description, or any field sourced from a third-party gemspec, package.json, or API response should be treated the same as user input — because in practice, it often is attacker-controlled.
  • Don't rely on escaping alone. shellescape, shellwords, and similar helpers reduce risk but don't eliminate the underlying architectural weakness of invoking a shell. Prefer APIs that skip the shell entirely whenever possible.
  • Use static analysis in CI. Semgrep's ruby.lang.security.dangerous-subshell.dangerous-subshell rule (and similar rules for Python's os.system, shell=True, Node's child_process.exec, etc.) catches this pattern automatically before it reaches production.
  • Map findings to CWE-78 (Improper Neutralization of Special Elements used in an OS Command) when triaging, and reference the OWASP Command Injection Prevention Cheat Sheet for broader guidance across languages.

Key Takeaways

  • The vulnerable pattern was `GIT_TERMINAL_PROMPT=0 git clone #{self.homepage.shellescape} ...` in append_git_data at plugin_scores_helper.rb:197 — a backtick subshell with interpolated, non-static data.
  • self.homepage originates from third-party plugin gemspec metadata, making it effectively untrusted input despite feeling like "internal" data.
  • shellescape reduced risk but didn't remove the underlying architectural issue of invoking a shell at all.
  • The fix — system({ "GIT_TERMINAL_PROMPT" => "0" }, "git", "clone", self.homepage, clone_folder) — passes arguments directly to git without shell parsing, closing off the injection primitive entirely.
  • This was flagged as "defensive hardening" rather than a proven exploit today, but removing such primitives proactively raises the bar against automated exploit-chaining tools.

How Orbis AppSec Detected This

  • Source: self.homepage, a value derived from third-party plugin gemspec metadata processed during Fastlane plugin scoring.
  • Sink: the backtick subshell `GIT_TERMINAL_PROMPT=0 git clone #{self.homepage.shellescape} #{clone_folder.shellescape}` in fastlane/helper/plugin_scores_helper.rb:197, which invokes /bin/sh -c with interpolated content.
  • Missing control: reliance on shellescape string-escaping instead of avoiding shell invocation altogether — a fragile, escaping-dependent mitigation rather than a structural fix.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
  • Fix: Replaced the backtick subshell with system({ "GIT_TERMINAL_PROMPT" => "0" }, "git", "clone", self.homepage, clone_folder), passing arguments directly to the git binary without shell interpretation.

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

This fix is a textbook example of hardening code that "worked fine" but carried a latent risk: a backtick subshell fed by data that isn't guaranteed to be static or trustworthy. Even with shellescape in place, the dangerous-subshell pattern in fastlane/helper/plugin_scores_helper.rb gave automated exploit tooling a primitive to potentially chain with other weaknesses. By switching to system() with an explicit argument array, the Fastlane helper now invokes git directly — no shell, no parsing, no escaping to get wrong. Whenever your Ruby code needs to shell out, favor the array form of system() or Open3 over backticks and interpolated strings; it's a small change with an outsized security payoff.

References

  • CWE-78: Improper Neutralization of Special Elements used in an OS Command — https://cwe.mitre.org/data/definitions/78.html
  • OWASP OS Command Injection Defense Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/OS_Command_Injection_Defense_Cheat_Sheet.html
  • Ruby documentation for Kernel#system — https://docs.ruby-lang.org/en/master/Kernel.html#method-i-system
  • Semgrep rule reference — https://semgrep.dev/r?q=ruby.lang.security.dangerous-subshell.dangerous-subshell
  • harden: detected non-static command inside ` in...

Frequently Asked Questions

What is command injection?

Command injection is a vulnerability where an attacker can execute arbitrary operating system commands because untrusted or unexpected data is interpolated into a shell command string instead of being passed as a discrete argument.

How do you prevent command injection in Ruby?

Avoid backticks, `%x{}`, `system("string")`, and `Kernel#exec` with interpolated strings. Instead use `system()` or `Open3.capture3()` with an argument array (e.g., `system("git", "clone", url, path)`), which never invokes a shell to parse the command.

What CWE is command injection?

Command injection maps to CWE-78: Improper Neutralization of Special Elements used in an OS Command.

Is shellescape enough to prevent command injection?

Not reliably. While `String#shellescape` reduces risk by escaping shell metacharacters, it still relies on correctly escaping every possible input, and any oversight or shell-parsing edge case can reintroduce injection. Passing arguments as an array to `system()` avoids the shell entirely and is a stronger guarantee.

Can static analysis detect command injection?

Yes. Static analysis tools like Semgrep can flag dangerous subshell patterns (e.g., `ruby.lang.security.dangerous-subshell.dangerous-subshell`) by detecting non-static values interpolated into backtick or `system("string")` calls, even before the code path is proven exploitable.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #30148

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.