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:
- Backticks always spawn a shell.
`command`in Ruby is sugar forKernel#(shell execution via/bin/sh -c "command"). Anything inside the backticks is first parsed by the shell, then executed. self.homepageis 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.shellescapeis 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.shellescapecall, 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 viaexecve-style semantics, passing each argument as a separate, literal string to thegitbinary. There is no/bin/sh -cstep 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. shellescapeis no longer needed — and that's the point. Becauseself.homepageandclone_folderare 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 invokinggitchanged.
Prevention & Best Practices
- Never use backticks,
%x{}, or single-stringsystem("...")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), orKernel.spawnwith 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-subshellrule (and similar rules for Python'sos.system,shell=True, Node'schild_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} ...`inappend_git_dataatplugin_scores_helper.rb:197— a backtick subshell with interpolated, non-static data. self.homepageoriginates from third-party plugin gemspec metadata, making it effectively untrusted input despite feeling like "internal" data.shellescapereduced 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 togitwithout 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}`infastlane/helper/plugin_scores_helper.rb:197, which invokes/bin/sh -cwith interpolated content. - Missing control: reliance on
shellescapestring-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 thegitbinary 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...