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

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.