Skip to content

imagecache: Verify layer content against its diffID before it enters the pool - #2279

Merged
Benjamin Elder (BenTheElder) merged 2 commits into
agent-substrate:mainfrom
igooch:imagecache-verify-diffid
Oct 20, 2026
Merged

Benjamin Elder (BenTheElder) merged 2 commits into
agent-substrate:mainfrom
igooch:imagecache-verify-diffid

Conversation

@igooch

@igooch igooch commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2082.

The bug

The layer pool is keyed by diffID and shared by every image on a node, but nothing checked a layer's content against its diffID:

  • The diffID comes from the image config, which the image's author controls. An image could claim a common base layer's diffID and plant its own content under it.
  • The old check compared a remote layer's DiffID() with the config's diffID, but go-containerregistry reads that DiffID() from the same config, so the check could never fail.
  • go-containerregistry checks the compressed digest only at EOF, and untar stops at the tar end marker, before EOF.

The fix

  • Unpack hashes the uncompressed stream, drains it to EOF, and rejects a mismatch before the layer is renamed into the pool.
  • The layer flight is keyed by blob as well as diffID, so a lying image fails only its own pull. Honest pulls of the same diffID run in their own flight. A pull's pins keep eviction off its layers, so the flight is only deduplication.
  • Concurrent pulls of the same diffID from different blobs (for example, the same layer recompressed) no longer share a flight. Each one downloads and unpacks the layer, and one rename wins.
  • A mismatch isn't retried per candidate credential, and pullWith logs it at WARN with the image.
  • The recorded size counts only what untar read.
  • The layout version moves to 2, so atelet refuses a pool whose layers were never checked.

Breaking change

Not in this PR

A node that already has a verified layer reuses it without downloading the new image's blob, so an image whose blob doesn't match its claimed diffID can still succeed there. The same gap lets an image mount any layer already on the node by claiming its diffID, without a credential for the registry that served it. Checking each image's own blob on reuse is the follow-up.

// A remote layer's DiffID() is read from this same config, so
// the claim is checked against the content at unpack instead.
diffID := cfgFile.RootFS.DiffIDs[i]
dir, err := s.ensureLayer(gctx, diffID, layer)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 should-fix 🟡 – ensureLayer shares one flight per diffID, but each caller brings its own blob and only the leader's blob is read. Now that a content mismatch is an error, an image that lies about a common base layer's diffID fails every honest pull of that diffID running at the same time. The pool stays clean, but this lets one image break other images' pulls on the node. One fix is to retry once with the caller's own layer when layerSF.Do returns an error with shared set.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed without retries where the layer flight is now keyed by blob as well as diffID, so a "lying" image fails only its own flight. That relies on #1509's pins, so this PR is now stacked on #1509.

Comment thread internal/imagecache/README.md Resolved
@igooch
igooch force-pushed the imagecache-verify-diffid branch 2 times, most recently from 58db03b to f1e10e9 Compare October 9, 2026 22:01
@destijl

Copy link
Copy Markdown
Collaborator

igooch: can we get this one in? Seems close, do you need ben to review again?

@igooch
igooch force-pushed the imagecache-verify-diffid branch from d86293b to 23c71ca Compare October 10, 2026 00:11
@igooch

igooch commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator Author

Greg Castle (@destijl) close, but it now depends on #1509 as the fix for Ben's comment needs #1509's pins. Could you or Benjamin Elder (@BenTheElder) review #1509?

@igooch
igooch force-pushed the imagecache-verify-diffid branch from 23c71ca to 0f0d1e4 Compare October 16, 2026 05:40
@igooch
igooch force-pushed the imagecache-verify-diffid branch from 0f0d1e4 to bd55c41 Compare October 16, 2026 05:56
@bowei Bowei Du (bowei) added area/security Security related issue/pr area/storage kind/bug Something isn't working / bugfixes labels Oct 19, 2026
@igooch
igooch force-pushed the imagecache-verify-diffid branch 3 times, most recently from fb2c437 to 15e2186 Compare October 19, 2026 23:43
@igooch
igooch marked this pull request as ready for review October 19, 2026 23:44
@igooch
igooch force-pushed the imagecache-verify-diffid branch 3 times, most recently from ced85b3 to ce851eb Compare October 20, 2026 00:03
…the pool

The layer pool is keyed by diffID and shared by every image on a node,
but the diffID comes from the image config, which the image's author
controls. Nothing checked that a layer's content matched it: the old
comparison read a remote layer's DiffID() from the same config. So the
first image to unpack a diffID decided what every later image with that
diffID mounted, and an image could claim a common base layer's diffID to
plant its own content under it.

The compressed digest was not checked either. go-containerregistry
verifies a blob against the manifest digest only when the stream reaches
EOF, and untar stops at the tar end marker, before EOF.

Unpack now hashes the uncompressed stream as it reads, drains it to EOF
so the compressed digest check runs, and requires the hash to match the
claimed diffID before the layer is renamed into the pool. On a mismatch
the temp tree is discarded and the pull fails. The recorded size counts
only what untar read, so trailing bytes cannot inflate the size GC
accounts for. A mismatch is the image's, so it is not retried with each
candidate credential.

A layer's flight is now keyed by its blob as well as its diffID. A
caller that joins a flight gets the leader's result, so with a diffID
key an image lying about a common layer's diffID would fail every honest
pull of that diffID in flight. With the blob in the key the lying image
leads, and fails, only its own flight. Pulls of the same blob still
share one. This relies on pins to keep eviction off a pull's layers; the
flight is only deduplication.

The on-disk layout is unchanged, so the layout version stays at 1.
Layers unpacked before this change were never verified and stay in the
pool until eviction retires them.
@igooch
igooch force-pushed the imagecache-verify-diffid branch from ce851eb to 9a3c261 Compare October 20, 2026 00:17
@BenTheElder

Copy link
Copy Markdown
Collaborator

#1509 merged with an outstanding nit today, reviewing this one again

Comment thread internal/imagecache/README.md Outdated
A layer already in the pool is reused without re-hashing, because it
was checked when it was written. The reusing image's own blob is never
downloaded, so it is never checked. Layers unpacked by an atelet that
lacked this check stay in the pool until eviction retires them.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 should-fix 🟡 – This keeps trusting every layer a pre-fix atelet unpacked, and eviction never retires a layer that images keep using, so a poisoned base layer can outlive the fix. The issue thread settled on bumping the layout version so New refuses the old pool. #2392 also moves layoutVersion to "2", so whichever lands second needs "3".

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically we don't do back-compat yet, but we do very soon, and bumping this integer is ~free.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can technically be handled in a trivial follow-up, let's merge the bulk of it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bumped to 2.

diffID := cfgFile.RootFS.DiffIDs[i]
dir, err := s.ensureLayer(gctx, diffID, layer)
if err != nil {
return fmt.Errorf("while unpacking layer %s: %w", diffID, err)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 question 🟢 – Should an errDiffIDMismatch be logged here at WARN with the image digest? Today the only trace is this error string returned through the Run/Restore RPC, and ate.imagecache.requests counts it as a plain error. It is the one signal of a lying image or a tampering registry.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added. pullWith logs the mismatch with ref, digest, and diffID. I left the metric alone as a new error.type value needs a registry change, so that's better as its own PR.

Comment thread internal/imagecache/README.md Resolved
@BenTheElder
Benjamin Elder (BenTheElder) added this pull request to the merge queue Oct 20, 2026
Layers in a version 1 pool were never hashed against their diffID, and
eviction never retires a layer that images keep using. Bump the layout
version so atelet refuses the old pool, and document how to rebuild a
node's image cache.

Log a diffID mismatch at WARN with the image. The RPC error was its only
trace.
@igooch igooch added the breaking-change PRs with breaking changes label Oct 20, 2026
Merged via the queue into agent-substrate:main with commit 4b4fbb2 Oct 20, 2026
11 checks passed
@igooch

igooch commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator Author

These fixes missed the merge. 80e0659 (layout version 2, the mismatch WARN, and the upgrade.md rebuild steps) was pushed before the merge, but main got 9a3c261's content. I've opened #2979 with that commit.

ILLUM1N0X pushed a commit to ILLUM1N0X/substrate that referenced this pull request Oct 21, 2026
…gent-substrate#2979)

Follow-up to agent-substrate#2279. @BenTheElder's last review asked for these, and they
were pushed to agent-substrate#2279 before it merged, but main got the earlier commit.

- Layout version moves to 2. Layers in a version 1 pool may not have
been hashed against their diffID, and eviction never retires a layer
that images keep using, so atelet refuses the old pool.
- `pullWith` logs a diffID mismatch at WARN with ref, digest, and
diffID. The RPC error was its only trace.

## Breaking change

After an upgrade or rollback, atelet refuses the existing image cache.
Before 1.0 a minor upgrade is a reinstall, so the upgrade runbook does
not change.

agent-substrate#2392 also moves the layout version to 2. Whichever lands second takes
3.

Written with Claude Code.
@igooch
igooch deleted the imagecache-verify-diffid branch October 27, 2026 18:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/security Security related issue/pr area/storage breaking-change PRs with breaking changes kind/bug Something isn't working / bugfixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Security] Image Layer Cache Poisoning via Unverified DiffID Keying

4 participants