* [meta-virtualization][wrynose][PATCH 3/5] containerd: Fix CVE-2026-53489
2026-10-16 10:06 [meta-virtualization][wrynose][PATCH 1/5] containerd: Fix CVE-2026-50195 Deepak Rathore -X (deeratho - E INFOCHIPS PRIVATE LIMITED at Cisco)
2026-10-16 10:06 ` [meta-virtualization][wrynose][PATCH 2/5] containerd: Fix CVE-2026-53492 Deepak Rathore -X (deeratho - E INFOCHIPS PRIVATE LIMITED at Cisco)
@ 2026-10-16 10:06 ` Deepak Rathore -X (deeratho - E INFOCHIPS PRIVATE LIMITED at Cisco)
2026-10-22 19:09 ` Bruce Ashfield
2026-10-16 10:06 ` [meta-virtualization][wrynose][PATCH 4/5] containerd: Fix CVE-2026-53495 Deepak Rathore -X (deeratho - E INFOCHIPS PRIVATE LIMITED at Cisco)
` (2 subsequent siblings)
4 siblings, 1 reply; 10+ messages in thread
From: Deepak Rathore -X (deeratho - E INFOCHIPS PRIVATE LIMITED at Cisco) @ 2026-10-16 10:06 UTC (permalink / raw)
To: meta-virtualization
From: Deepak Rathore <deeratho@cisco.com>
This patch applies the upstream release/2.2 backport for CVE-2026-53489.
The upstream fix commit is referenced in [1], and the public CVE record
is referenced in [2].
[1] https://github.com/containerd/containerd/commit/357652293053d0cd3ed565f718b0050aa662ae1a
[2] https://nvd.nist.gov/vuln/detail/CVE-2026-53489
Signed-off-by: Deepak Rathore <deeratho@cisco.com>
---
.../containerd/CVE-2026-53489.patch | 529 ++++++++++++++++++
.../containerd/containerd_git.bb | 1 +
2 files changed, 530 insertions(+)
create mode 100644 recipes-containers/containerd/containerd/CVE-2026-53489.patch
diff --git a/recipes-containers/containerd/containerd/CVE-2026-53489.patch b/recipes-containers/containerd/containerd/CVE-2026-53489.patch
new file mode 100644
index 00000000..74915474
--- /dev/null
+++ b/recipes-containers/containerd/containerd/CVE-2026-53489.patch
@@ -0,0 +1,529 @@
+From 0e6f9d96caa978fd688a2d94140e95f348bfe74f Mon Sep 17 00:00:00 2001
+From: Brian Goff <cpuguy83@gmail.com>
+Date: Tue, 9 Jun 2026 11:37:18 -0700
+Subject: [PATCH 3/4] cri: make checkpoint restore robust to unexpected archive
+ content
+
+The CRI checkpoint restore path unpacked checkpoint archive/OCI image content
+directly into the container's persistent state directory and read files such as
+container.log back from it with a symlink-following copy. Checkpoint content is
+externally provided, so make restore more defensive about what it unpacks and
+how it reads those files back.
+
+Behavior changes:
+
+- Only unpack regular files and directories from the checkpoint archive.
+
+- Unpack checkpoint content into a dedicated <state>/ctrd-restore
+ subdirectory created fresh rather than into the state dir itself, so
+ checkpoint content cannot collide with containerd's own files (e.g.
+ the "status" blob). Restore and cleanup operate on that subdir;
+ cleanup is now a single RemoveAll of it.
+
+CVE: CVE-2026-53489
+Upstream-Status: Backport [https://github.com/containerd/containerd/commit/357652293053d0cd3ed565f718b0050aa662ae1a]
+
+Backport Changes:
+- CVE-2026-53492 and this upstream commit independently add
+ internal/cri/server/container_checkpoint_linux_test.go. Applying the
+ selected series produces an add/add conflict for that file.
+- Resolve the conflict by merging the upstream CVE-2026-53489 checkpoint
+ archive and symlink tests into the file created by CVE-2026-53492. This
+ retains both CVEs' upstream test coverage without changing security code.
+
+Signed-off-by: Brian Goff <cpuguy83@gmail.com>
+(cherry picked from commit 8196411f24065533093be4c7ad874c23b06178f3)
+Signed-off-by: Brian Goff <cpuguy83@gmail.com>
+(cherry picked from commit 357652293053d0cd3ed565f718b0050aa662ae1a)
+Signed-off-by: Deepak Rathore <deeratho@cisco.com>
+---
+ .../cri/server/container_checkpoint_linux.go | 186 ++++++++++++++----
+ .../server/container_checkpoint_linux_test.go | 130 ++++++++++++
+ internal/cri/server/container_start.go | 35 ++--
+ 3 files changed, 286 insertions(+), 65 deletions(-)
+
+diff --git a/internal/cri/server/container_checkpoint_linux.go b/internal/cri/server/container_checkpoint_linux.go
+index 13cd3e2e1..26f0d9b25 100644
+--- a/internal/cri/server/container_checkpoint_linux.go
++++ b/internal/cri/server/container_checkpoint_linux.go
+@@ -31,7 +31,6 @@ import (
+ "time"
+
+ crmetadata "github.com/checkpoint-restore/checkpointctl/lib"
+- "github.com/checkpoint-restore/go-criu/v7/stats"
+ "github.com/checkpoint-restore/go-criu/v7/utils"
+ "github.com/containerd/containerd/api/types/runc/options"
+ "github.com/containerd/containerd/v2/client"
+@@ -56,9 +55,86 @@ import (
+ "github.com/opencontainers/image-spec/identity"
+ v1 "github.com/opencontainers/image-spec/specs-go/v1"
+ spec "github.com/opencontainers/runtime-spec/specs-go"
++ "golang.org/x/sys/unix"
+ runtime "k8s.io/cri-api/pkg/apis/runtime/v1"
++
++ // TODO: This package import is kept to prevent merge conflicts while integrating multiple
++ // branches, specifically because this changes vendoring.
++ _ "github.com/checkpoint-restore/go-criu/v7/stats"
+ )
+
++// copyNoFollow copies the regular file at src to dst without following a symlink
++// at the final path component of src.
++//
++// The checkpoint code reads files (container.log, status, stats-dump, dump.log)
++// out of the container state directory, which can contain entries unpacked from a
++// checkpoint archive or OCI image. Those entries are externally provided, so they
++// are read defensively.
++//
++// src is first lstat'd (which does not follow a final-component symlink) and must
++// be a regular file; non-regular entries are rejected before src is ever opened.
++// src is then opened with O_NOFOLLOW as a belt-and-suspenders guard in case the
++// entry changes type between the lstat and the open.
++func copyNoFollow(src, dst string, perm os.FileMode) error {
++ fi, err := os.Lstat(src)
++ if err != nil {
++ return err
++ }
++ if !fi.Mode().IsRegular() {
++ return fmt.Errorf("refusing to copy %s: not a regular file", src)
++ }
++
++ in, err := os.OpenFile(src, os.O_RDONLY|unix.O_NOFOLLOW, 0)
++ if err != nil {
++ return err
++ }
++ defer in.Close()
++
++ out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
++ if err != nil {
++ return err
++ }
++ defer out.Close()
++
++ _, err = io.Copy(out, in)
++ return err
++}
++
++// checkpointArchiveEntryAllowed reports whether a tar entry from a checkpoint
++// archive may be unpacked. Legitimate checkpoint archives contain only regular
++// files and directories; other entry types (symlinks, hardlinks, device and fifo
++// nodes) are not produced by the checkpoint code and are rejected as a hardening
++// measure.
++func checkpointArchiveEntryAllowed(hdr *tar.Header) bool {
++ switch hdr.Typeflag {
++ //nolint:staticcheck // TypeRegA is deprecated but we may still receive an external tar with TypeRegA
++ case tar.TypeReg, tar.TypeRegA, tar.TypeDir, tar.TypeXGlobalHeader:
++ return true
++ default:
++ return false
++ }
++}
++
++// assertCheckpointDirSafe verifies that the populated restore directory contains
++// only regular files and directories.
++//
++// The OCI-image restore path copies checkpoint content into the restore dir with
++// fs.CopyDir, which (unlike the tar unpack filter) faithfully recreates any
++// symlinks and special files present in the image. Restore-time consumers open
++// paths under this directory, so non-regular entries are rejected before they run.
++func assertCheckpointDirSafe(root string) error {
++ return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
++ if err != nil {
++ return err
++ }
++ // d.Type() reports the entry type without following symlinks.
++ if d.IsDir() || d.Type().IsRegular() {
++ return nil
++ }
++ return fmt.Errorf("refusing to restore checkpoint: %s is not a regular file or directory", path)
++ })
++}
++
+ // checkIfCheckpointOCIImage returns checks if the input refers to a checkpoint image.
+ // It returns the StorageImageID of the image the input resolves to, nil otherwise.
+ func (c *criService) checkIfCheckpointOCIImage(ctx context.Context, input string) (string, error) {
+@@ -187,6 +263,12 @@ func (c *criService) CRImportCheckpoint(
+ }(archiveFile)
+
+ filter := archive.WithFilter(func(hdr *tar.Header) (bool, error) {
++ // Reject entry types the checkpoint code never produces (symlinks,
++ // hardlinks, device/fifo nodes) so they are not recreated on disk.
++ if !checkpointArchiveEntryAllowed(hdr) {
++ log.G(ctx).Warnf("Skipping unexpected checkpoint archive entry %q (type %d)", hdr.Name, hdr.Typeflag)
++ return false, nil
++ }
+ // The checkpoint archive is unpacked twice if using a tar file directly.
+ // The first time only the metadata files are relevant to prepare the
+ // restore operation. This filter function ignores the large parts of
+@@ -381,16 +463,39 @@ func (c *criService) CRImportCheckpoint(
+ return "", err
+ }
+
++ // Confine all checkpoint content to a dedicated subdirectory of the container
++ // state dir instead of unpacking it directly into the state dir, so it cannot
++ // collide with containerd's own files there. Create it fresh; RemoveAll unlinks
++ // any pre-existing entry without following it.
++ restoreDir := filepath.Join(containerRootDir, checkpointRestoreDir)
++ if err := os.RemoveAll(restoreDir); err != nil {
++ return "", err
++ }
++ if err := os.Mkdir(restoreDir, 0o700); err != nil {
++ return "", err
++ }
++
+ if restoreStorageImageID != "" {
+- if err := fs.CopyDir(containerRootDir, mountPoint); err != nil {
++ if err := fs.CopyDir(restoreDir, mountPoint); err != nil {
+ return "", err
+ }
+ if err := mount.UnmountAll(mountPoint, 0); err != nil {
+ return "", err
+ }
++ // fs.CopyDir recreates any symlinks/special files from the image; reject
++ // them here so restore-time consumers only ever open regular files.
++ if err := assertCheckpointDirSafe(restoreDir); err != nil {
++ return "", err
++ }
+ } else {
+ // unpack the checkpoint archive
+ filter := archive.WithFilter(func(hdr *tar.Header) (bool, error) {
++ // Reject entry types the checkpoint code never produces (symlinks,
++ // hardlinks, device/fifo nodes) so they are not recreated on disk.
++ if !checkpointArchiveEntryAllowed(hdr) {
++ log.G(ctx).Warnf("Skipping unexpected checkpoint archive entry %q (type %d)", hdr.Name, hdr.Typeflag)
++ return false, nil
++ }
+ excludePatterns := []string{
+ crmetadata.ConfigDumpFile,
+ crmetadata.SpecDumpFile,
+@@ -408,19 +513,21 @@ func (c *criService) CRImportCheckpoint(
+
+ // Start from the beginning of the checkpoint archive
+ archiveFile.Seek(0, 0)
+- _, err = archive.Apply(ctx, containerRootDir, archiveFile, []archive.ApplyOpt{filter}...)
++ _, err = archive.Apply(ctx, restoreDir, archiveFile, []archive.ApplyOpt{filter}...)
+
+ if err != nil {
+- return "", fmt.Errorf("unpacking of checkpoint archive %s failed: %w", containerRootDir, err)
++ return "", fmt.Errorf("unpacking of checkpoint archive %s failed: %w", restoreDir, err)
+ }
+ }
+- log.G(ctx).Debugf("Unpacked checkpoint in %s", containerRootDir)
++ log.G(ctx).Debugf("Unpacked checkpoint in %s", restoreDir)
+
+- // Restore container log file (if it exists)
+- containerLog := filepath.Join(containerRootDir, "container.log")
+- _, err = c.os.Stat(containerLog)
+- if err == nil {
+- if err := c.os.CopyFile(containerLog, meta.LogPath, 0600); err != nil {
++ // Restore container log file (if it exists).
++ //
++ // container.log was unpacked from a checkpoint archive/OCI image, so it is
++ // copied without following a final-component symlink.
++ containerLog := filepath.Join(restoreDir, "container.log")
++ if err := copyNoFollow(containerLog, meta.LogPath, 0600); err != nil {
++ if !errors.Is(err, os.ErrNotExist) {
+ return "", fmt.Errorf("restoring container log file %s failed: %w", containerLog, err)
+ }
+ }
+@@ -488,7 +595,25 @@ func (c *criService) CheckpointContainer(ctx context.Context, r *runtime.Checkpo
+ if err != nil {
+ return nil, fmt.Errorf("failed to get task for container %q: %w", r.GetContainerId(), err)
+ }
+- img, err := task.Checkpoint(ctx, []client.CheckpointTaskOpts{withCheckpointOpts(i.Runtime.Name, c.getContainerRootDir(r.GetContainerId()))}...)
++
++ cpPath := filepath.Join(c.getContainerRootDir(container.ID), "ctrd-checkpoint")
++ // ctrd-checkpoint may already exist from a prior checkpoint operation. RemoveAll
++ // unlinks any existing entry (including a symlink) itself rather than its target,
++ // so creating the directory afterwards cannot write through a link.
++ if err := os.RemoveAll(cpPath); err != nil {
++ return nil, err
++ }
++ if err := os.Mkdir(cpPath, 0o700); err != nil {
++ return nil, err
++ }
++ defer os.RemoveAll(cpPath)
++
++ // Point CRIU's work directory (where it writes dump.log and stats-dump) at the
++ // dedicated, freshly-created checkpoint dir instead of the persistent container
++ // state dir. Otherwise checkpoint creation litters those files into the state
++ // dir where they are never cleaned up; here they land directly where they are
++ // archived from and are removed with cpPath.
++ img, err := task.Checkpoint(ctx, []client.CheckpointTaskOpts{withCheckpointOpts(i.Runtime.Name, cpPath)}...)
+ if err != nil {
+ return nil, fmt.Errorf("checkpointing container %q failed: %w", r.GetContainerId(), err)
+ }
+@@ -513,43 +638,20 @@ func (c *criService) CheckpointContainer(ctx context.Context, r *runtime.Checkpo
+ return nil, fmt.Errorf("failed to unmarshall blob into checkpoint data OCI index: %w", err)
+ }
+
+- cpPath := filepath.Join(c.getContainerRootDir(r.GetContainerId()), "ctrd-checkpoint")
+- if err := os.MkdirAll(cpPath, 0o700); err != nil {
+- return nil, err
+- }
+- defer os.RemoveAll(cpPath)
+-
+- // This internal containerd file is used by checkpointctl for
+- // checkpoint archive analysis.
+- if err := c.os.CopyFile(
+- filepath.Join(c.getContainerRootDir(r.GetContainerId()), crmetadata.StatusFile),
++ // This internal containerd file is used by checkpointctl for checkpoint archive
++ // analysis. It lives in the container state dir, which can hold files from a
++ // prior checkpoint operation, so it is read without following symlinks.
++ if err := copyNoFollow(
++ filepath.Join(c.getContainerRootDir(container.ID), crmetadata.StatusFile),
+ filepath.Join(cpPath, crmetadata.StatusFile),
+ 0o600,
+ ); err != nil {
+ return nil, err
+ }
+
+- // This file is created by CRIU and includes timing analysis.
+- // Also used by checkpointctl
+- if err := c.os.CopyFile(
+- filepath.Join(c.getContainerRootDir(r.GetContainerId()), stats.StatsDump),
+- filepath.Join(cpPath, stats.StatsDump),
+- 0o600,
+- ); err != nil {
+- return nil, err
+- }
+-
+- // The log file created by CRIU. This file could be missing.
+- // Let's ignore errors if the file is missing.
+- if err := c.os.CopyFile(
+- filepath.Join(c.getContainerRootDir(r.GetContainerId()), crmetadata.DumpLogFile),
+- filepath.Join(cpPath, crmetadata.DumpLogFile),
+- 0o600,
+- ); err != nil {
+- if !errors.Is(errors.Unwrap(err), os.ErrNotExist) {
+- return nil, err
+- }
+- }
++ // dump.log and stats-dump are written directly into cpPath by CRIU via its
++ // work directory (see withCheckpointOpts above), so they are already present
++ // for archiving and do not need to be copied out of the container state dir.
+
+ // Save the existing container log file
+ _, err = c.os.Stat(criContainerStatus.GetStatus().GetLogPath())
+diff --git a/internal/cri/server/container_checkpoint_linux_test.go b/internal/cri/server/container_checkpoint_linux_test.go
+index 3010e5233..740a52100 100644
+--- a/internal/cri/server/container_checkpoint_linux_test.go
++++ b/internal/cri/server/container_checkpoint_linux_test.go
+@@ -19,13 +19,19 @@
+ package server
+
+ import (
++ "archive/tar"
+ "context"
++ "errors"
++ "os"
++ "path/filepath"
+ "sync"
+ "testing"
+
+ "github.com/containerd/log"
+ "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
++ "github.com/stretchr/testify/require"
++ "golang.org/x/sys/unix"
+ )
+
+ type testLogHook struct {
+@@ -105,3 +111,127 @@ func TestFilterAndMergeAnnotations(t *testing.T) {
+ })
+ }
+ }
++
++func TestCopyNoFollowRegularFile(t *testing.T) {
++ dir := t.TempDir()
++ src := filepath.Join(dir, "src")
++ dst := filepath.Join(dir, "dst")
++ require.NoError(t, os.WriteFile(src, []byte("hello"), 0o644))
++
++ require.NoError(t, copyNoFollow(src, dst, 0o600))
++
++ data, err := os.ReadFile(dst)
++ require.NoError(t, err)
++ assert.Equal(t, "hello", string(data))
++
++ info, err := os.Stat(dst)
++ require.NoError(t, err)
++ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
++}
++
++func TestCopyNoFollowMissingSource(t *testing.T) {
++ dir := t.TempDir()
++ src := filepath.Join(dir, "does-not-exist")
++ dst := filepath.Join(dir, "dst")
++
++ err := copyNoFollow(src, dst, 0o600)
++ require.Error(t, err)
++ assert.True(t, errors.Is(err, os.ErrNotExist), "expected ErrNotExist, got %v", err)
++ assert.NoFileExists(t, dst)
++}
++
++func TestCopyNoFollowSymlinkSourceNotFollowed(t *testing.T) {
++ dir := t.TempDir()
++
++ // A stand-in for a file outside the copy that a symlink might point at.
++ secret := filepath.Join(dir, "outside-target")
++ require.NoError(t, os.WriteFile(secret, []byte("outside-content"), 0o600))
++
++ src := filepath.Join(dir, "container.log")
++ require.NoError(t, os.Symlink(secret, src))
++ dst := filepath.Join(dir, "dst")
++
++ err := copyNoFollow(src, dst, 0o600)
++ require.Error(t, err)
++ // A symlink is not a "missing file"; it must surface as an error, not be skipped.
++ assert.False(t, errors.Is(err, os.ErrNotExist))
++ // And the linked-to content must not have been copied into the destination.
++ assert.NoFileExists(t, dst)
++}
++
++func TestCopyNoFollowRejectsFIFO(t *testing.T) {
++ dir := t.TempDir()
++ src := filepath.Join(dir, "fifo")
++ require.NoError(t, unix.Mkfifo(src, 0o600))
++ dst := filepath.Join(dir, "dst")
++
++ // Must return promptly with an error rather than blocking on the FIFO open.
++ err := copyNoFollow(src, dst, 0o600)
++ require.Error(t, err)
++ assert.False(t, errors.Is(err, os.ErrNotExist))
++ assert.NoFileExists(t, dst)
++}
++
++func TestCopyNoFollowRejectsDirectory(t *testing.T) {
++ dir := t.TempDir()
++ src := filepath.Join(dir, "adir")
++ require.NoError(t, os.Mkdir(src, 0o700))
++ dst := filepath.Join(dir, "dst")
++
++ err := copyNoFollow(src, dst, 0o600)
++ require.Error(t, err)
++ assert.NoFileExists(t, dst)
++}
++
++func TestAssertCheckpointDirSafe(t *testing.T) {
++ t.Run("regular files and dirs allowed", func(t *testing.T) {
++ root := t.TempDir()
++ require.NoError(t, os.MkdirAll(filepath.Join(root, "checkpoint"), 0o700))
++ require.NoError(t, os.WriteFile(filepath.Join(root, "checkpoint", "img"), []byte("x"), 0o600))
++ require.NoError(t, os.WriteFile(filepath.Join(root, "rootfs-diff.tar"), []byte("x"), 0o600))
++ assert.NoError(t, assertCheckpointDirSafe(root))
++ })
++
++ t.Run("symlink rejected", func(t *testing.T) {
++ root := t.TempDir()
++ require.NoError(t, os.Symlink("/some/outside/path", filepath.Join(root, "rootfs-diff.tar")))
++ assert.Error(t, assertCheckpointDirSafe(root))
++ })
++
++ t.Run("symlink nested in subdir rejected", func(t *testing.T) {
++ root := t.TempDir()
++ require.NoError(t, os.MkdirAll(filepath.Join(root, "checkpoint"), 0o700))
++ require.NoError(t, os.Symlink("/some/outside/path", filepath.Join(root, "checkpoint", "pages-1.img")))
++ assert.Error(t, assertCheckpointDirSafe(root))
++ })
++
++ t.Run("fifo rejected", func(t *testing.T) {
++ root := t.TempDir()
++ require.NoError(t, unix.Mkfifo(filepath.Join(root, "fifo"), 0o600))
++ assert.Error(t, assertCheckpointDirSafe(root))
++ })
++}
++
++func TestCheckpointArchiveEntryAllowed(t *testing.T) {
++ for _, tc := range []struct {
++ name string
++ typ byte
++ allowed bool
++ }{
++ {"regular", tar.TypeReg, true},
++ //nolint:staticcheck // TypeRegA is deprecated but external tars may still use it
++ {"regular-A", tar.TypeRegA, true},
++ {"directory", tar.TypeDir, true},
++ {"global-header", tar.TypeXGlobalHeader, true},
++ {"symlink", tar.TypeSymlink, false},
++ {"hardlink", tar.TypeLink, false},
++ {"char-device", tar.TypeChar, false},
++ {"block-device", tar.TypeBlock, false},
++ {"fifo", tar.TypeFifo, false},
++ } {
++ t.Run(tc.name, func(t *testing.T) {
++ got := checkpointArchiveEntryAllowed(&tar.Header{Typeflag: tc.typ, Name: tc.name})
++ assert.Equal(t, tc.allowed, got)
++ })
++ }
++}
+diff --git a/internal/cri/server/container_start.go b/internal/cri/server/container_start.go
+index c0fb8c50f..0c7653cad 100644
+--- a/internal/cri/server/container_start.go
++++ b/internal/cri/server/container_start.go
+@@ -31,7 +31,6 @@ import (
+ runtime "k8s.io/cri-api/pkg/apis/runtime/v1"
+
+ crmetadata "github.com/checkpoint-restore/checkpointctl/lib"
+- "github.com/checkpoint-restore/go-criu/v7/stats"
+ containerd "github.com/containerd/containerd/v2/client"
+ cio "github.com/containerd/containerd/v2/internal/cri/io"
+ containerstore "github.com/containerd/containerd/v2/internal/cri/store/container"
+@@ -41,6 +40,12 @@ import (
+ cioutil "github.com/containerd/containerd/v2/pkg/ioutil"
+ )
+
++// checkpointRestoreDir is the subdirectory under a container's persistent state
++// directory into which checkpoint content (CRIU images, container.log,
++// rootfs-diff.tar, ...) is unpacked during restore. Confining it here keeps
++// checkpoint content from colliding with containerd's own files in the state dir.
++const checkpointRestoreDir = "ctrd-restore"
++
+ // StartContainer starts the container.
+ func (c *criService) StartContainer(ctx context.Context, r *runtime.StartContainerRequest) (retRes *runtime.StartContainerResponse, retErr error) {
+ span := tracing.SpanFromContext(ctx)
+@@ -112,7 +117,7 @@ func (c *criService) StartContainer(ctx context.Context, r *runtime.StartContain
+ pid, err := container.Restore(
+ ctx,
+ ioCreation,
+- filepath.Join(c.getContainerRootDir(r.GetContainerId()), crmetadata.CheckpointDirectory),
++ filepath.Join(c.getContainerRootDir(r.GetContainerId()), checkpointRestoreDir, crmetadata.CheckpointDirectory),
+ )
+
+ if err != nil {
+@@ -155,28 +160,12 @@ func (c *criService) StartContainer(ctx context.Context, r *runtime.StartContain
+ // It handles the TaskExit event and update container state after this.
+ c.startContainerExitMonitor(context.Background(), id, task.Pid(), exitCh)
+
+- // cleanup checkpoint artifacts after restore
+- cleanup := [...]string{
+- crmetadata.RestoreLogFile,
+- crmetadata.DumpLogFile,
+- stats.StatsDump,
+- stats.StatsRestore,
+- crmetadata.NetworkStatusFile,
+- crmetadata.RootFsDiffTar,
+- crmetadata.DeletedFilesFile,
+- crmetadata.CheckpointDirectory,
+- crmetadata.StatusDumpFile,
+- crmetadata.ConfigDumpFile,
+- crmetadata.SpecDumpFile,
+- "container.log",
+- }
+- for _, del := range cleanup {
+- file := filepath.Join(c.getContainerRootDir(r.GetContainerId()), del)
+- err = os.RemoveAll(file)
+- if err != nil {
+- log.G(ctx).Infof("Non-fatal: removal of checkpoint file (%s) failed: %v", file, err)
+- }
++ // cleanup checkpoint artifacts after restore.
++ restoreDir := filepath.Join(c.getContainerRootDir(r.GetContainerId()), checkpointRestoreDir)
++ if err := os.RemoveAll(restoreDir); err != nil {
++ log.G(ctx).Warnf("Non-fatal: removal of checkpoint restore dir (%s) failed: %v", restoreDir, err)
+ }
++
+ log.G(ctx).Infof("Restored container %s successfully", r.GetContainerId())
+ return &runtime.StartContainerResponse{}, nil
+ }
+--
+2.35.6
+
diff --git a/recipes-containers/containerd/containerd_git.bb b/recipes-containers/containerd/containerd_git.bb
index b2e9187a..af756ba8 100644
--- a/recipes-containers/containerd/containerd_git.bb
+++ b/recipes-containers/containerd/containerd_git.bb
@@ -15,6 +15,7 @@ SRC_URI = "git://github.com/containerd/containerd;branch=release/2.2;protocol=ht
file://CVE-2026-53488.patch \
file://CVE-2026-50195.patch \
file://CVE-2026-53492.patch \
+ file://CVE-2026-53489.patch \
"
# Apache-2.0 for containerd
--
2.35.6
^ permalink raw reply related [flat|nested] 10+ messages in thread* [meta-virtualization][wrynose][PATCH 5/5] containerd: Fix CVE-2026-53493
2026-10-16 10:06 [meta-virtualization][wrynose][PATCH 1/5] containerd: Fix CVE-2026-50195 Deepak Rathore -X (deeratho - E INFOCHIPS PRIVATE LIMITED at Cisco)
` (2 preceding siblings ...)
2026-10-16 10:06 ` [meta-virtualization][wrynose][PATCH 4/5] containerd: Fix CVE-2026-53495 Deepak Rathore -X (deeratho - E INFOCHIPS PRIVATE LIMITED at Cisco)
@ 2026-10-16 10:06 ` Deepak Rathore -X (deeratho - E INFOCHIPS PRIVATE LIMITED at Cisco)
2026-10-22 19:09 ` Bruce Ashfield
2026-10-22 19:09 ` [meta-virtualization][wrynose][PATCH 1/5] containerd: Fix CVE-2026-50195 Bruce Ashfield
4 siblings, 1 reply; 10+ messages in thread
From: Deepak Rathore -X (deeratho - E INFOCHIPS PRIVATE LIMITED at Cisco) @ 2026-10-16 10:06 UTC (permalink / raw)
To: meta-virtualization
From: Deepak Rathore <deeratho@cisco.com>
This patch applies the upstream release/2.2 backport for
CVE-2026-53493. It bounds Dispatch concurrency and references, then
bounds Walk references during image pull processing to prevent
uncontrolled descriptor-graph traversal and resource exhaustion.
The individual upstream backport commits are referenced in [1] and [2].
The public CVE advisory is referenced in [3].
[1] https://github.com/containerd/containerd/commit/efd11fc9c2935f1a7ce6626b348d1461bac34acf
[2] https://github.com/containerd/containerd/commit/053c0cc412f2a36c77a973d0b62ff4fa4815a9bb
[3] https://github.com/containerd/containerd/security/advisories/GHSA-pg57-6jwg-q645
Signed-off-by: Deepak Rathore <deeratho@cisco.com>
---
.../containerd/CVE-2026-53493_p1.patch | 646 ++++++++++++++++++
.../containerd/CVE-2026-53493_p2.patch | 329 +++++++++
.../containerd/containerd_git.bb | 2 +
3 files changed, 977 insertions(+)
create mode 100644 recipes-containers/containerd/containerd/CVE-2026-53493_p1.patch
create mode 100644 recipes-containers/containerd/containerd/CVE-2026-53493_p2.patch
diff --git a/recipes-containers/containerd/containerd/CVE-2026-53493_p1.patch b/recipes-containers/containerd/containerd/CVE-2026-53493_p1.patch
new file mode 100644
index 00000000..b4fd6ded
--- /dev/null
+++ b/recipes-containers/containerd/containerd/CVE-2026-53493_p1.patch
@@ -0,0 +1,646 @@
+From 95d5ef2d8204a639c8f452d8457b82406802a540 Mon Sep 17 00:00:00 2001
+From: Chris Henzie <chrishenzie@gmail.com>
+Date: Tue, 28 Jul 2026 14:09:57 -0700
+Subject: [PATCH 1/2] Bound Dispatch concurrency and references
+
+Traverse breadth-first, one level at a time. Acquire the limiter before
+starting each handler goroutine. Default to 32 concurrent handlers when
+no limiter is provided. Children wait until every handler in the current
+level finishes.
+
+Preserve duplicate visits and return an error wrapping
+ErrResourceExhausted when the traversal exceeds 10,000 references.
+Count children as handlers return them to bound the descriptors retained
+for the next level.
+
+CVE: CVE-2026-53493
+Upstream-Status: Backport [https://github.com/containerd/containerd/commit/efd11fc9c2935f1a7ce6626b348d1461bac34acf]
+
+Assisted-by: Claude Code
+Signed-off-by: Chris Henzie <chrishenzie@gmail.com>
+(cherry picked from commit 94e83c14c8aac963e24e28105dab9c9af812a2a8)
+Signed-off-by: Chris Henzie <chrishenzie@gmail.com>
+(cherry picked from commit efd11fc9c2935f1a7ce6626b348d1461bac34acf)
+Signed-off-by: Deepak Rathore <deeratho@cisco.com>
+---
+ core/images/handlers.go | 101 ++++++--
+ core/images/handlers_test.go | 469 +++++++++++++++++++++++++++++++++++
+ 2 files changed, 543 insertions(+), 27 deletions(-)
+ create mode 100644 core/images/handlers_test.go
+
+diff --git a/core/images/handlers.go b/core/images/handlers.go
+index 245b9ac43..a60835f97 100644
+--- a/core/images/handlers.go
++++ b/core/images/handlers.go
+@@ -23,6 +23,7 @@ import (
+ "slices"
+ "sort"
+ "strings"
++ "sync"
+
+ "github.com/containerd/containerd/v2/core/content"
+ "github.com/containerd/errdefs"
+@@ -32,6 +33,16 @@ import (
+ "golang.org/x/sync/semaphore"
+ )
+
++const (
++ // defaultMaxConcurrency bounds the number of concurrent handler
++ // goroutines when Dispatch is called with a nil limiter.
++ defaultMaxConcurrency = 32
++
++ // maxReferences caps the references a single Dispatch admits, duplicates
++ // included.
++ maxReferences = 10_000
++)
++
+ var (
+ // ErrSkipDesc is used to skip processing of a descriptor and
+ // its descendants.
+@@ -146,45 +157,81 @@ func WalkNotEmpty(ctx context.Context, handler Handler, descs ...ocispec.Descrip
+ // handler may return `ErrSkipDesc` to signal to the dispatcher to not traverse
+ // any children.
+ //
+-// A concurrency limiter can be passed in to limit the number of concurrent
+-// handlers running. When limiter is nil, there is no limit.
++// The limiter bounds concurrent handlers. When limiter is nil,
++// the limit is 32.
+ //
+ // Typically, this function will be used with `FetchHandler`, often composed
+ // with other handlers.
+ //
+ // If any handler returns an error, the dispatch session will be canceled.
++//
++// Each call is limited to 10,000 references, including duplicates.
++// Exceeding this limit returns an error wrapping
++// [errdefs.ErrResourceExhausted].
+ func Dispatch(ctx context.Context, handler Handler, limiter *semaphore.Weighted, descs ...ocispec.Descriptor) error {
+- eg, ctx2 := errgroup.WithContext(ctx)
+- for _, desc := range descs {
+- if limiter != nil {
+- if err := limiter.Acquire(ctx, 1); err != nil {
+- return err
+- }
++ if len(descs) == 0 {
++ return nil
++ }
++ if err := ctx.Err(); err != nil {
++ return err
++ }
++
++ if limiter == nil {
++ limiter = semaphore.NewWeighted(defaultMaxConcurrency)
++ }
++
++ var (
++ mu sync.Mutex
++ next []ocispec.Descriptor
++ referenceCount int
++ )
++
++ admit := func(descs []ocispec.Descriptor) error {
++ mu.Lock()
++ defer mu.Unlock()
++ if referenceCount+len(descs) > maxReferences {
++ return fmt.Errorf("too many descriptors (limit %d): %w", maxReferences, errdefs.ErrResourceExhausted)
+ }
++ referenceCount += len(descs)
++ next = append(next, descs...)
++ return nil
++ }
++
++ if err := admit(descs); err != nil {
++ return err
++ }
+
+- eg.Go(func() error {
+- desc := desc
++ for len(next) > 0 {
++ level := next
++ next = nil
+
+- children, err := handler.Handle(ctx2, desc)
+- if limiter != nil {
+- limiter.Release(1)
++ eg, egCtx := errgroup.WithContext(ctx)
++ for _, desc := range level {
++ // Acquire here to bound the number of handler goroutines.
++ if err := limiter.Acquire(egCtx, 1); err != nil {
++ break
+ }
+- if err != nil {
+- if errors.Is(err, ErrSkipDesc) {
+- return nil // don't traverse the children.
++ eg.Go(func() error {
++ defer limiter.Release(1)
++ children, err := handler.Handle(egCtx, desc)
++ if err != nil {
++ if errors.Is(err, ErrSkipDesc) {
++ return nil // don't traverse the children.
++ }
++ return err
+ }
+- return err
+- }
+-
+- if len(children) > 0 {
+- return Dispatch(ctx2, handler, limiter, children...)
+- }
+-
+- return nil
+- })
++ return admit(children)
++ })
++ }
++ if err := eg.Wait(); err != nil {
++ return err
++ }
++ // Acquire can fail on cancellation even if every handler returns nil.
++ if err := ctx.Err(); err != nil {
++ return err
++ }
+ }
+-
+- return eg.Wait()
++ return nil
+ }
+
+ // ChildrenHandler decodes well-known manifest types and returns their children.
+diff --git a/core/images/handlers_test.go b/core/images/handlers_test.go
+new file mode 100644
+index 000000000..5c4e54e86
+--- /dev/null
++++ b/core/images/handlers_test.go
+@@ -0,0 +1,469 @@
++/*
++ Copyright The containerd Authors.
++
++ Licensed under the Apache License, Version 2.0 (the "License");
++ you may not use this file except in compliance with the License.
++ You may obtain a copy of the License at
++
++ http://www.apache.org/licenses/LICENSE-2.0
++
++ Unless required by applicable law or agreed to in writing, software
++ distributed under the License is distributed on an "AS IS" BASIS,
++ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
++ See the License for the specific language governing permissions and
++ limitations under the License.
++*/
++
++package images
++
++import (
++ "context"
++ "errors"
++ "fmt"
++ "sync"
++ "sync/atomic"
++ "testing"
++ "time"
++
++ "github.com/containerd/errdefs"
++ digest "github.com/opencontainers/go-digest"
++ ocispec "github.com/opencontainers/image-spec/specs-go/v1"
++ "github.com/stretchr/testify/assert"
++ "github.com/stretchr/testify/require"
++ "golang.org/x/sync/errgroup"
++ "golang.org/x/sync/semaphore"
++)
++
++func newDesc(id string) ocispec.Descriptor {
++ return ocispec.Descriptor{
++ Digest: digest.FromString(id),
++ MediaType: ocispec.MediaTypeImageLayer,
++ Size: 1,
++ }
++}
++
++func digestSet(digests []digest.Digest) map[digest.Digest]struct{} {
++ set := make(map[digest.Digest]struct{}, len(digests))
++ for _, d := range digests {
++ set[d] = struct{}{}
++ }
++ return set
++}
++
++func TestDispatchTreeTraversal(t *testing.T) {
++ root := newDesc("root")
++ child1 := newDesc("child1")
++ child2 := newDesc("child2")
++ grandchild1 := newDesc("grandchild1")
++
++ graph := map[digest.Digest][]ocispec.Descriptor{
++ root.Digest: {child1, child2},
++ child1.Digest: {grandchild1},
++ }
++
++ var visited []digest.Digest
++ var mu sync.Mutex
++ handler := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ mu.Lock()
++ visited = append(visited, desc.Digest)
++ mu.Unlock()
++ return graph[desc.Digest], nil
++ })
++
++ err := Dispatch(context.Background(), handler, nil, root)
++ require.NoError(t, err)
++
++ set := digestSet(visited)
++ assert.Contains(t, set, root.Digest)
++ assert.Contains(t, set, child1.Digest)
++ assert.Contains(t, set, child2.Digest)
++ assert.Contains(t, set, grandchild1.Digest)
++ assert.Len(t, visited, 4)
++}
++
++func TestDispatchRepeatedReferences(t *testing.T) {
++ root := newDesc("root")
++ childA := newDesc("A")
++ childB := newDesc("B")
++ shared := newDesc("shared")
++
++ graph := map[digest.Digest][]ocispec.Descriptor{
++ root.Digest: {childA, childB},
++ childA.Digest: {shared},
++ childB.Digest: {shared},
++ }
++
++ tests := []struct {
++ name string
++ dispatch func(context.Context, Handler, *semaphore.Weighted, ...ocispec.Descriptor) error
++ wantTotalCalls int32
++ wantSharedCalls int32
++ }{
++ {
++ name: "Dispatch visits every reference",
++ dispatch: Dispatch,
++ wantTotalCalls: 5,
++ wantSharedCalls: 2,
++ },
++ }
++
++ for _, tt := range tests {
++ t.Run(tt.name, func(t *testing.T) {
++ var totalCalls atomic.Int32
++ var sharedCalls atomic.Int32
++ handler := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ totalCalls.Add(1)
++ if desc.Digest == shared.Digest {
++ sharedCalls.Add(1)
++ }
++ return graph[desc.Digest], nil
++ })
++
++ err := tt.dispatch(context.Background(), handler, nil, root)
++ require.NoError(t, err)
++ assert.Equal(t, tt.wantTotalCalls, totalCalls.Load())
++ assert.Equal(t, tt.wantSharedCalls, sharedCalls.Load())
++ })
++ }
++}
++
++func TestDispatchErrSkipDesc(t *testing.T) {
++ root := newDesc("root")
++ child1 := newDesc("child1")
++ child2 := newDesc("child2")
++ grandchild := newDesc("grandchild")
++
++ graph := map[digest.Digest][]ocispec.Descriptor{
++ root.Digest: {child1, child2},
++ child1.Digest: {grandchild},
++ }
++
++ var visited []digest.Digest
++ var mu sync.Mutex
++ handler := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ mu.Lock()
++ visited = append(visited, desc.Digest)
++ mu.Unlock()
++ if desc.Digest == child1.Digest {
++ return graph[desc.Digest], ErrSkipDesc
++ }
++ return graph[desc.Digest], nil
++ })
++
++ err := Dispatch(context.Background(), handler, nil, root)
++ require.NoError(t, err)
++
++ set := digestSet(visited)
++ assert.Contains(t, set, root.Digest)
++ assert.Contains(t, set, child1.Digest)
++ assert.Contains(t, set, child2.Digest)
++ assert.NotContains(t, set, grandchild.Digest, "grandchild must not be visited when its parent returns ErrSkipDesc")
++}
++
++func TestDispatchConcurrencyLimit(t *testing.T) {
++ tests := []struct {
++ name string
++ limiter *semaphore.Weighted
++ maxConcurrency int32
++ numChildren int
++ }{
++ {
++ name: "With explicit limiter",
++ limiter: semaphore.NewWeighted(3),
++ maxConcurrency: 3,
++ numChildren: 3 * 5,
++ },
++ {
++ name: "With default limiter",
++ limiter: nil,
++ maxConcurrency: defaultMaxConcurrency,
++ numChildren: defaultMaxConcurrency * 5,
++ },
++ }
++
++ for _, tt := range tests {
++ t.Run(tt.name, func(t *testing.T) {
++ root := newDesc("root")
++ children := make([]ocispec.Descriptor, tt.numChildren)
++ for i := range tt.numChildren {
++ children[i] = newDesc(fmt.Sprintf("child-%d", i))
++ }
++
++ graph := map[digest.Digest][]ocispec.Descriptor{
++ root.Digest: children,
++ }
++
++ var concurrent, peak atomic.Int32
++ var exceeded atomic.Bool
++
++ handler := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ n := concurrent.Add(1)
++ if n > tt.maxConcurrency {
++ exceeded.Store(true)
++ }
++ for {
++ old := peak.Load()
++ if n <= old || peak.CompareAndSwap(old, n) {
++ break
++ }
++ }
++ defer concurrent.Add(-1)
++
++ // Keep handlers running long enough to overlap.
++ time.Sleep(10 * time.Millisecond)
++ return graph[desc.Digest], nil
++ })
++
++ err := Dispatch(context.Background(), handler, tt.limiter, root)
++ require.NoError(t, err)
++
++ assert.False(t, exceeded.Load(), "concurrent handlers must not exceed capacity")
++ assert.Equal(t, tt.maxConcurrency, peak.Load(),
++ "concurrent handlers must reach the capacity the caller asked for")
++ })
++ }
++}
++
++func TestDispatchSharedLimiter(t *testing.T) {
++ const (
++ weight = 4
++ callers = 4
++ perTree = 12
++ )
++
++ limiter := semaphore.NewWeighted(weight)
++
++ var concurrent atomic.Int32
++ var exceeded atomic.Bool
++ visited := make([]atomic.Int32, callers)
++
++ var eg errgroup.Group
++ for c := range callers {
++ eg.Go(func() error {
++ root := newDesc(fmt.Sprintf("root-%d", c))
++ children := make([]ocispec.Descriptor, perTree)
++ for i := range perTree {
++ children[i] = newDesc(fmt.Sprintf("caller-%d-child-%d", c, i))
++ }
++ graph := map[digest.Digest][]ocispec.Descriptor{root.Digest: children}
++
++ handler := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ if concurrent.Add(1) > weight {
++ exceeded.Store(true)
++ }
++ defer concurrent.Add(-1)
++ visited[c].Add(1)
++
++ time.Sleep(2 * time.Millisecond)
++ return graph[desc.Digest], nil
++ })
++
++ return Dispatch(context.Background(), handler, limiter, root)
++ })
++ }
++
++ done := make(chan error, 1)
++ go func() { done <- eg.Wait() }()
++
++ select {
++ case err := <-done:
++ require.NoError(t, err)
++ case <-time.After(30 * time.Second):
++ t.Fatal("shared-limiter traversals did not complete; a caller is starved or deadlocked")
++ }
++
++ assert.False(t, exceeded.Load(), "combined concurrent handlers must not exceed the shared limiter weight")
++ for c := range callers {
++ assert.Equal(t, int32(perTree+1), visited[c].Load(),
++ "caller %d must visit every descriptor in its own graph", c)
++ }
++}
++
++func TestDispatchReferenceLimit(t *testing.T) {
++ // Each root is below the cap on its own; their combined references exceed it.
++ rootA := newDesc("root-a")
++ rootB := newDesc("root-b")
++
++ childrenA := make([]ocispec.Descriptor, maxReferences/2)
++ childrenB := make([]ocispec.Descriptor, maxReferences/2)
++ for i := range childrenA {
++ childrenA[i] = newDesc(fmt.Sprintf("a-%d", i))
++ childrenB[i] = newDesc(fmt.Sprintf("b-%d", i))
++ }
++
++ handler := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ switch desc.Digest {
++ case rootA.Digest:
++ return childrenA, nil
++ case rootB.Digest:
++ return childrenB, nil
++ }
++ return nil, nil
++ })
++
++ require.ErrorIs(t, Dispatch(context.Background(), handler, nil, rootA, rootB), errdefs.ErrResourceExhausted)
++
++ acceptRoot := newDesc("accept-root")
++ exact := make([]ocispec.Descriptor, maxReferences-1)
++ for i := range exact {
++ exact[i] = newDesc(fmt.Sprintf("accept-%d", i))
++ }
++ acceptHandler := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ if desc.Digest == acceptRoot.Digest {
++ return exact, nil
++ }
++ return nil, nil
++ })
++ require.NoError(t, Dispatch(context.Background(), acceptHandler, nil, acceptRoot))
++}
++
++func TestDispatchRejectsChildrenBeforeVisiting(t *testing.T) {
++ root := newDesc("root")
++ children := make([]ocispec.Descriptor, maxReferences)
++ for i := range children {
++ children[i] = newDesc(fmt.Sprintf("node-%d", i))
++ }
++ var childVisits atomic.Int32
++ handler := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ if desc.Digest == root.Digest {
++ return children, nil
++ }
++ childVisits.Add(1)
++ return nil, nil
++ })
++
++ require.ErrorIs(t, Dispatch(context.Background(), handler, nil, root), errdefs.ErrResourceExhausted)
++ assert.Equal(t, int32(0), childVisits.Load(), "children of an over-limit slice must not be visited")
++}
++
++func TestDispatchDuplicateRoots(t *testing.T) {
++ root := newDesc("root")
++
++ tests := []struct {
++ name string
++ dispatch func(context.Context, Handler, *semaphore.Weighted, ...ocispec.Descriptor) error
++ wantCalls int32
++ }{
++ {"Dispatch visits each root", Dispatch, 2},
++ }
++
++ for _, tt := range tests {
++ t.Run(tt.name, func(t *testing.T) {
++ var calls atomic.Int32
++ handler := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ calls.Add(1)
++ return nil, nil
++ })
++ require.NoError(t, tt.dispatch(context.Background(), handler, nil, root, root))
++ assert.Equal(t, tt.wantCalls, calls.Load())
++ })
++ }
++}
++
++func TestDispatchCountsDuplicateReferences(t *testing.T) {
++ root := newDesc("root")
++ dup := newDesc("dup")
++ children := make([]ocispec.Descriptor, maxReferences+1)
++ for i := range children {
++ children[i] = dup
++ }
++ fanout := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ if desc.Digest == root.Digest {
++ return children, nil
++ }
++ return nil, nil
++ })
++ require.ErrorIs(t, Dispatch(context.Background(), fanout, nil, root), errdefs.ErrResourceExhausted)
++
++ a, b := newDesc("a"), newDesc("b")
++ cycle := map[digest.Digest][]ocispec.Descriptor{a.Digest: {b}, b.Digest: {a}}
++ cyclic := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ return cycle[desc.Digest], nil
++ })
++ require.ErrorIs(t, Dispatch(context.Background(), cyclic, nil, a), errdefs.ErrResourceExhausted)
++}
++
++func TestDispatchErrorPropagation(t *testing.T) {
++ root := newDesc("root")
++ child1 := newDesc("child1")
++ child2 := newDesc("child2")
++
++ graph := map[digest.Digest][]ocispec.Descriptor{
++ root.Digest: {child1, child2},
++ }
++
++ errTest := errors.New("handler error")
++
++ handler := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ if desc.Digest == child1.Digest {
++ return nil, errTest
++ }
++ return graph[desc.Digest], nil
++ })
++
++ err := Dispatch(context.Background(), handler, nil, root)
++ require.ErrorIs(t, err, errTest)
++}
++
++func TestDispatchContextCancellation(t *testing.T) {
++ ctx, cancel := context.WithCancel(context.Background())
++ cancel()
++
++ handler := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ return nil, nil
++ })
++
++ err := Dispatch(ctx, handler, nil, newDesc("root"))
++ require.ErrorIs(t, err, context.Canceled)
++}
++
++func TestDispatchContextCancellationMidFlight(t *testing.T) {
++ // Use more descriptors than the limiter admits so cancellation also covers
++ // handlers waiting to start.
++ const n = defaultMaxConcurrency * 4
++ descs := make([]ocispec.Descriptor, n)
++ for i := range n {
++ descs[i] = newDesc(fmt.Sprintf("cancel-%d", i))
++ }
++
++ ctx, cancel := context.WithCancel(context.Background())
++ defer cancel()
++
++ started := make(chan struct{}, n)
++ handler := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ started <- struct{}{}
++ <-ctx.Done()
++ return nil, ctx.Err()
++ })
++
++ go func() {
++ for range defaultMaxConcurrency {
++ <-started
++ }
++ cancel()
++ }()
++
++ err := Dispatch(ctx, handler, nil, descs...)
++ require.ErrorIs(t, err, context.Canceled)
++}
++
++func TestDispatchCancellationWithNoHandlerError(t *testing.T) {
++ const n = defaultMaxConcurrency * 4
++ descs := make([]ocispec.Descriptor, n)
++ for i := range n {
++ descs[i] = newDesc(fmt.Sprintf("cancel-queued-%d", i))
++ }
++
++ ctx, cancel := context.WithCancel(context.Background())
++ defer cancel()
++
++ handler := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ if desc.Digest == descs[0].Digest {
++ cancel()
++ }
++ return nil, nil
++ })
++
++ err := Dispatch(ctx, handler, nil, descs...)
++ require.ErrorIs(t, err, context.Canceled)
++}
+--
+2.53.0
+
diff --git a/recipes-containers/containerd/containerd/CVE-2026-53493_p2.patch b/recipes-containers/containerd/containerd/CVE-2026-53493_p2.patch
new file mode 100644
index 00000000..d76cfb2e
--- /dev/null
+++ b/recipes-containers/containerd/containerd/CVE-2026-53493_p2.patch
@@ -0,0 +1,329 @@
+From 77f0eab284656ca3b609ad347a83bb6adb9d6833 Mon Sep 17 00:00:00 2001
+From: Chris Henzie <chrishenzie@gmail.com>
+Date: Tue, 28 Jul 2026 14:18:32 -0700
+Subject: [PATCH 2/2] Bound Walk references
+
+Return an error wrapping ErrResourceExhausted when the walk exceeds
+10,000 references. Preserve duplicate visits and count every reference
+toward the limit across recursive calls and roots.
+
+CVE: CVE-2026-53493
+Upstream-Status: Backport [https://github.com/containerd/containerd/commit/053c0cc412f2a36c77a973d0b62ff4fa4815a9bb]
+
+Assisted-by: Claude Code
+Signed-off-by: Chris Henzie <chrishenzie@gmail.com>
+(cherry picked from commit 4f5f32636d47f051751065cf824a10da70c619fe)
+Signed-off-by: Chris Henzie <chrishenzie@gmail.com>
+(cherry picked from commit 053c0cc412f2a36c77a973d0b62ff4fa4815a9bb)
+Signed-off-by: Deepak Rathore <deeratho@cisco.com>
+---
+ core/images/handlers.go | 25 +++-
+ core/images/handlers_test.go | 240 +++++++++++++++++++++++++++++++++++
+ 2 files changed, 261 insertions(+), 4 deletions(-)
+
+diff --git a/core/images/handlers.go b/core/images/handlers.go
+index a60835f97..7d1ec35cb 100644
+--- a/core/images/handlers.go
++++ b/core/images/handlers.go
+@@ -38,8 +38,8 @@ const (
+ // goroutines when Dispatch is called with a nil limiter.
+ defaultMaxConcurrency = 32
+
+- // maxReferences caps the references a single Dispatch admits, duplicates
+- // included.
++ // maxReferences caps the references in a single Dispatch or Walk.
++ // Duplicate references count toward this limit.
+ maxReferences = 10_000
+ )
+
+@@ -99,9 +99,26 @@ func Handlers(handlers ...Handler) HandlerFunc {
+ //
+ // This differs from dispatch in that each sibling resource is considered
+ // synchronously.
++//
++// Each call is limited to 10,000 references, including duplicates.
++// Exceeding this limit returns an error wrapping
++// [errdefs.ErrResourceExhausted].
+ func Walk(ctx context.Context, handler Handler, descs ...ocispec.Descriptor) error {
+- for _, desc := range descs {
++ w := &walker{}
++ return w.walk(ctx, handler, descs...)
++}
+
++type walker struct {
++ referenceCount int
++}
++
++func (w *walker) walk(ctx context.Context, handler Handler, descs ...ocispec.Descriptor) error {
++ if w.referenceCount+len(descs) > maxReferences {
++ return fmt.Errorf("too many descriptors (limit %d): %w", maxReferences, errdefs.ErrResourceExhausted)
++ }
++ w.referenceCount += len(descs)
++
++ for _, desc := range descs {
+ children, err := handler.Handle(ctx, desc)
+ if err != nil {
+ if errors.Is(err, ErrSkipDesc) {
+@@ -111,7 +128,7 @@ func Walk(ctx context.Context, handler Handler, descs ...ocispec.Descriptor) err
+ }
+
+ if len(children) > 0 {
+- if err := Walk(ctx, handler, children...); err != nil {
++ if err := w.walk(ctx, handler, children...); err != nil {
+ return err
+ }
+ }
+diff --git a/core/images/handlers_test.go b/core/images/handlers_test.go
+index 5c4e54e86..acad816d7 100644
+--- a/core/images/handlers_test.go
++++ b/core/images/handlers_test.go
+@@ -50,6 +50,246 @@ func digestSet(digests []digest.Digest) map[digest.Digest]struct{} {
+ return set
+ }
+
++func TestWalkTreeTraversal(t *testing.T) {
++ root := newDesc("root")
++ child1 := newDesc("child1")
++ child2 := newDesc("child2")
++ grandchild1 := newDesc("grandchild1")
++
++ graph := map[digest.Digest][]ocispec.Descriptor{
++ root.Digest: {child1, child2},
++ child1.Digest: {grandchild1},
++ }
++
++ var visited []digest.Digest
++ handler := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ visited = append(visited, desc.Digest)
++ return graph[desc.Digest], nil
++ })
++
++ err := Walk(context.Background(), handler, root)
++ require.NoError(t, err)
++
++ want := []digest.Digest{root.Digest, child1.Digest, grandchild1.Digest, child2.Digest}
++ assert.Equal(t, want, visited)
++}
++
++func TestWalkRepeatedReferences(t *testing.T) {
++ root := newDesc("root")
++ childA := newDesc("A")
++ childB := newDesc("B")
++ shared := newDesc("shared")
++
++ graph := map[digest.Digest][]ocispec.Descriptor{
++ root.Digest: {childA, childB},
++ childA.Digest: {shared},
++ childB.Digest: {shared},
++ }
++
++ tests := []struct {
++ name string
++ walk func(context.Context, Handler, ...ocispec.Descriptor) error
++ wantTotalCalls int
++ wantSharedCalls int
++ }{
++ {
++ name: "Walk visits every reference",
++ walk: Walk,
++ wantTotalCalls: 5,
++ wantSharedCalls: 2,
++ },
++ }
++
++ for _, tt := range tests {
++ t.Run(tt.name, func(t *testing.T) {
++ var totalCalls, sharedCalls int
++ handler := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ totalCalls++
++ if desc.Digest == shared.Digest {
++ sharedCalls++
++ }
++ return graph[desc.Digest], nil
++ })
++
++ err := tt.walk(context.Background(), handler, root)
++ require.NoError(t, err)
++ assert.Equal(t, tt.wantTotalCalls, totalCalls)
++ assert.Equal(t, tt.wantSharedCalls, sharedCalls)
++ })
++ }
++}
++
++func TestWalkErrSkipDesc(t *testing.T) {
++ root := newDesc("root")
++ child1 := newDesc("child1")
++ child2 := newDesc("child2")
++ grandchild := newDesc("grandchild")
++
++ graph := map[digest.Digest][]ocispec.Descriptor{
++ root.Digest: {child1, child2},
++ child1.Digest: {grandchild},
++ }
++
++ var visited []digest.Digest
++ handler := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ visited = append(visited, desc.Digest)
++ if desc.Digest == child1.Digest {
++ return graph[desc.Digest], ErrSkipDesc
++ }
++ return graph[desc.Digest], nil
++ })
++
++ err := Walk(context.Background(), handler, root)
++ require.NoError(t, err)
++
++ set := digestSet(visited)
++ assert.Contains(t, set, root.Digest)
++ assert.Contains(t, set, child1.Digest)
++ assert.Contains(t, set, child2.Digest)
++ assert.NotContains(t, set, grandchild.Digest, "grandchild must not be visited when its parent returns ErrSkipDesc")
++}
++
++func TestWalkReferenceLimit(t *testing.T) {
++ root := newDesc("root")
++ handlerReturning := func(childCount int) Handler {
++ children := make([]ocispec.Descriptor, childCount)
++ for i := range children {
++ children[i] = newDesc(fmt.Sprintf("node-%d", i))
++ }
++ return HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ if desc.Digest == root.Digest {
++ return children, nil
++ }
++ return nil, nil
++ })
++ }
++
++ require.NoError(t, Walk(context.Background(), handlerReturning(maxReferences-1), root))
++ require.ErrorIs(t, Walk(context.Background(), handlerReturning(maxReferences), root), errdefs.ErrResourceExhausted)
++}
++
++func TestWalkRejectsChildrenBeforeVisiting(t *testing.T) {
++ root := newDesc("root")
++ children := make([]ocispec.Descriptor, maxReferences)
++ for i := range children {
++ children[i] = newDesc(fmt.Sprintf("node-%d", i))
++ }
++ var childVisits int
++ handler := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ if desc.Digest == root.Digest {
++ return children, nil
++ }
++ childVisits++
++ return nil, nil
++ })
++
++ require.ErrorIs(t, Walk(context.Background(), handler, root), errdefs.ErrResourceExhausted)
++ assert.Equal(t, 0, childVisits, "children of an over-limit slice must not be visited")
++}
++
++func TestWalkErrorPropagation(t *testing.T) {
++ root := newDesc("root")
++ child1 := newDesc("child1")
++ child2 := newDesc("child2")
++
++ graph := map[digest.Digest][]ocispec.Descriptor{
++ root.Digest: {child1, child2},
++ }
++
++ errTest := errors.New("handler error")
++
++ handler := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ if desc.Digest == child1.Digest {
++ return nil, errTest
++ }
++ return graph[desc.Digest], nil
++ })
++
++ err := Walk(context.Background(), handler, root)
++ require.ErrorIs(t, err, errTest)
++}
++
++func TestWalkDuplicateRoots(t *testing.T) {
++ root := newDesc("root")
++
++ tests := []struct {
++ name string
++ walk func(context.Context, Handler, ...ocispec.Descriptor) error
++ wantCalls int
++ }{
++ {"Walk visits each root", Walk, 2},
++ }
++
++ for _, tt := range tests {
++ t.Run(tt.name, func(t *testing.T) {
++ var calls int
++ handler := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ calls++
++ return nil, nil
++ })
++ require.NoError(t, tt.walk(context.Background(), handler, root, root))
++ assert.Equal(t, tt.wantCalls, calls)
++ })
++ }
++}
++
++func TestWalkCountsDuplicateReferences(t *testing.T) {
++ root := newDesc("root")
++ dup := newDesc("dup")
++ children := make([]ocispec.Descriptor, maxReferences+1)
++ for i := range children {
++ children[i] = dup
++ }
++ fanout := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ if desc.Digest == root.Digest {
++ return children, nil
++ }
++ return nil, nil
++ })
++ require.ErrorIs(t, Walk(context.Background(), fanout, root), errdefs.ErrResourceExhausted)
++
++ a, b := newDesc("a"), newDesc("b")
++ cycle := map[digest.Digest][]ocispec.Descriptor{a.Digest: {b}, b.Digest: {a}}
++ cyclic := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ return cycle[desc.Digest], nil
++ })
++ require.ErrorIs(t, Walk(context.Background(), cyclic, a), errdefs.ErrResourceExhausted)
++}
++
++func TestWalkNotEmpty(t *testing.T) {
++ root := newDesc("root")
++ child := newDesc("child")
++
++ t.Run("returns nil when a handler produces children", func(t *testing.T) {
++ graph := map[digest.Digest][]ocispec.Descriptor{root.Digest: {child}}
++ handler := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ return graph[desc.Digest], nil
++ })
++ require.NoError(t, WalkNotEmpty(context.Background(), handler, root))
++ })
++
++ t.Run("returns ErrEmptyWalk when everything is filtered out", func(t *testing.T) {
++ handler := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ return nil, nil
++ })
++ require.ErrorIs(t, WalkNotEmpty(context.Background(), handler, root), ErrEmptyWalk)
++ })
++
++ t.Run("propagates the reference-cap error", func(t *testing.T) {
++ children := make([]ocispec.Descriptor, maxReferences+1)
++ for i := range children {
++ children[i] = newDesc(fmt.Sprintf("node-%d", i))
++ }
++ handler := HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
++ if desc.Digest == root.Digest {
++ return children, nil
++ }
++ return nil, nil
++ })
++ require.ErrorIs(t, WalkNotEmpty(context.Background(), handler, root), errdefs.ErrResourceExhausted)
++ })
++}
++
+ func TestDispatchTreeTraversal(t *testing.T) {
+ root := newDesc("root")
+ child1 := newDesc("child1")
+--
+2.53.0
+
diff --git a/recipes-containers/containerd/containerd_git.bb b/recipes-containers/containerd/containerd_git.bb
index 2b037110..48d41dbc 100644
--- a/recipes-containers/containerd/containerd_git.bb
+++ b/recipes-containers/containerd/containerd_git.bb
@@ -17,6 +17,8 @@ SRC_URI = "git://github.com/containerd/containerd;branch=release/2.2;protocol=ht
file://CVE-2026-53492.patch \
file://CVE-2026-53489.patch \
file://CVE-2026-53495.patch \
+ file://CVE-2026-53493_p1.patch \
+ file://CVE-2026-53493_p2.patch \
"
# Apache-2.0 for containerd
--
2.35.6
^ permalink raw reply related [flat|nested] 10+ messages in thread