Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
272 changes: 272 additions & 0 deletions SPECS/containerd2/CVE-2026-47262.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
From 30708e8d1142287e9c6bb839f1b3f84c71ca4485 Mon Sep 17 00:00:00 2001
From: Chris Henzie <chrishenzie@gmail.com>
Date: Fri, 15 May 2026 22:19:37 +0000
Subject: [PATCH 1/5] Bound user-database file reads in openUserFile

openUserFile now stats the opened file, refuses anything that is not a
regular file, and wraps the returned fs.File so reads are capped at
maxUserFileBytes (10 MiB). All callers of openUserFile read either
etc/passwd or etc/group; both are regular files on real systems, well
under the cap.

The cap and the regular-file check together bound parser memory use
when reading user-database files of unexpected shape or size.

Adds tests for the cap and for the non-regular file rejection. The cap
test covers three boundary points: a small pad (trailing entry parsed),
a pad placing the entry's last byte exactly on the cap (still parsed),
and a pad past the cap (read returns an "exceeds" error).

Assisted-by: Antigravity
Signed-off-by: Chris Henzie <chrishenzie@gmail.com>
(cherry picked from commit 7b05ec421d0a07b33964c74145b6bf5dff58f476)
Signed-off-by: Chris Henzie <chrishenzie@gmail.com>
---
pkg/oci/spec_opts.go | 56 +++++++++-
pkg/oci/spec_opts_user_bounds_test.go | 146 ++++++++++++++++++++++++++
2 files changed, 200 insertions(+), 2 deletions(-)
create mode 100644 pkg/oci/spec_opts_user_bounds_test.go

diff --git a/pkg/oci/spec_opts.go b/pkg/oci/spec_opts.go
index c298e4bb2..c989552db 100644
--- a/pkg/oci/spec_opts.go
+++ b/pkg/oci/spec_opts.go
@@ -24,6 +24,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "io"
"io/fs"
"math"
"os"
@@ -1800,10 +1801,13 @@ type readLinker interface {
// openUserFile attempts to open a file within the root fs.
// It handles cases where the file is an absolute symlink (e.g., NixOS /etc/passwd -> /nix/store/...),
// which triggers "path escapes from parent" errors in Go 1.24+ due to stricter os.DirFS validation.
+//
+// The returned file rejects non-regular sources and returns an error if more
+// than maxUserFileBytes are read from it.
func openUserFile(root fs.FS, name string) (fs.File, error) {
f, err := root.Open(name)
if err == nil {
- return f, nil
+ return wrapUserFile(f, name)
}

// Check if the FS implements our local ReadLink interface.
@@ -1820,7 +1824,11 @@ func openUserFile(root fs.FS, name string) (fs.File, error) {
if rerr == nil {
// filepath.Rel might return OS-specific separators (backslashes on Windows).
// fs.Open strictly expects forward slashes, so we convert it.
- return root.Open(filepath.ToSlash(rel))
+ f, oerr := root.Open(filepath.ToSlash(rel))
+ if oerr != nil {
+ return nil, oerr
+ }
+ return wrapUserFile(f, name)
}
}
}
@@ -1829,3 +1837,47 @@ func openUserFile(root fs.FS, name string) (fs.File, error) {
// Return the original error if we couldn't resolve it
return nil, err
}
+
+// maxUserFileBytes caps how much data is read from any user-database file
+// opened via openUserFile. Real systems keep these files well under 1 MiB;
+// 10 MiB is generous headroom while keeping peak memory during
+// user.ParsePasswd/ParseGroup bounded to single-digit MiB.
+const maxUserFileBytes = 10 << 20
+
+// wrapUserFile rejects non-regular sources and returns an fs.File that
+// errors out if more than maxUserFileBytes are read from it.
+func wrapUserFile(f fs.File, name string) (fs.File, error) {
+ info, err := f.Stat()
+ if err != nil {
+ f.Close()
+ return nil, fmt.Errorf("stat %s: %w", name, err)
+ }
+ if !info.Mode().IsRegular() {
+ f.Close()
+ return nil, fmt.Errorf("%s is not a regular file", name)
+ }
+ return &limitedFile{
+ File: f,
+ // Allow one byte past the cap so an overflow surfaces as an
+ // error rather than a silent EOF that the parser would treat as
+ // a clean end-of-file (and miss any entries past the cap).
+ r: &io.LimitedReader{R: f, N: maxUserFileBytes + 1},
+ name: name,
+ }, nil
+}
+
+// limitedFile is an fs.File whose Read returns an error once more than
+// maxUserFileBytes have been read.
+type limitedFile struct {
+ fs.File
+ r *io.LimitedReader
+ name string
+}
+
+func (l *limitedFile) Read(p []byte) (int, error) {
+ n, err := l.r.Read(p)
+ if l.r.N == 0 {
+ return n, fmt.Errorf("%q exceeds %d bytes", l.name, maxUserFileBytes)
+ }
+ return n, err
+}
diff --git a/pkg/oci/spec_opts_user_bounds_test.go b/pkg/oci/spec_opts_user_bounds_test.go
new file mode 100644
index 000000000..54384f79a
--- /dev/null
+++ b/pkg/oci/spec_opts_user_bounds_test.go
@@ -0,0 +1,146 @@
+/*
+ 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 oci
+
+import (
+ "bytes"
+ "errors"
+ "io/fs"
+ "testing"
+ "testing/fstest"
+ "time"
+
+ "github.com/moby/sys/user"
+ "github.com/stretchr/testify/assert"
+)
+
+// TestOpenUserFileCapsReads asserts the boundary behavior of the read cap:
+// well below, ending exactly at, and past maxUserFileBytes.
+func TestOpenUserFileCapsReads(t *testing.T) {
+ t.Parallel()
+
+ beyond := []byte("\nbeyond:x:42:\n")
+
+ for _, tc := range []struct {
+ name string
+ padBytes int
+ wantGids []uint32
+ wantErr bool
+ }{
+ {
+ name: "pad below cap, beyond is parsed",
+ padBytes: 100,
+ wantGids: []uint32{42},
+ },
+ {
+ name: "beyond ends exactly at cap, is parsed",
+ padBytes: maxUserFileBytes - len(beyond),
+ wantGids: []uint32{42},
+ },
+ {
+ name: "pad past cap, read errors out",
+ padBytes: maxUserFileBytes,
+ wantErr: true,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ data := append(bytes.Repeat([]byte{0}, tc.padBytes), beyond...)
+ fsys := fstest.MapFS{
+ "etc/group": &fstest.MapFile{Data: data, Mode: 0o644},
+ }
+
+ gids, err := getSupplementalGroupsFromFS(fsys, func(g user.Group) bool {
+ return g.Name == "beyond"
+ })
+ if tc.wantErr {
+ assert.ErrorContains(t, err, "exceeds")
+ return
+ }
+ assert.NoError(t, err)
+ assert.Equal(t, tc.wantGids, gids)
+ })
+ }
+}
+
+// TestOpenUserFileRejectsNonRegularFiles verifies that non-regular files
+// are refused before any byte is read from them.
+func TestOpenUserFileRejectsNonRegularFiles(t *testing.T) {
+ t.Parallel()
+
+ for _, tc := range []struct {
+ name string
+ mode fs.FileMode
+ }{
+ {name: "char device", mode: fs.ModeDevice | fs.ModeCharDevice | 0o666},
+ {name: "socket", mode: fs.ModeSocket | 0o666},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ f := &nonRegularFile{mode: tc.mode}
+ rootFS := singleFileFS{name: "etc/group", file: f}
+
+ _, err := getSupplementalGroupsFromFS(rootFS, nil)
+ assert.Error(t, err)
+ assert.False(t, f.readCalled, "Read should not be called on non-regular file")
+ })
+ }
+}
+
+// nonRegularFile implements fs.File and reports a configurable non-regular
+// mode via Stat.
+type nonRegularFile struct {
+ mode fs.FileMode
+ readCalled bool
+}
+
+func (f *nonRegularFile) Read([]byte) (int, error) {
+ f.readCalled = true
+ return 0, errors.New("read should not be called on non-regular file")
+}
+
+func (f *nonRegularFile) Stat() (fs.FileInfo, error) {
+ return nonRegularFileInfo{mode: f.mode}, nil
+}
+func (f *nonRegularFile) Close() error { return nil }
+
+type nonRegularFileInfo struct {
+ mode fs.FileMode
+}
+
+func (nonRegularFileInfo) Name() string { return "group" }
+func (nonRegularFileInfo) Size() int64 { return 0 }
+func (i nonRegularFileInfo) Mode() fs.FileMode { return i.mode }
+func (nonRegularFileInfo) ModTime() time.Time { return time.Time{} }
+func (nonRegularFileInfo) IsDir() bool { return false }
+func (nonRegularFileInfo) Sys() any { return nil }
+
+// singleFileFS routes a single name to a single fs.File and returns
+// fs.ErrNotExist for everything else.
+type singleFileFS struct {
+ name string
+ file fs.File
+}
+
+func (s singleFileFS) Open(name string) (fs.File, error) {
+ if name == s.name {
+ return s.file, nil
+ }
+ return nil, fs.ErrNotExist
+}
--
2.54.0.1189.g8c84645362-goog

67 changes: 67 additions & 0 deletions SPECS/containerd2/CVE-2026-50195.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
From cff57884176a1e6ba0857a417753d799958e0f46 Mon Sep 17 00:00:00 2001
From: Samuel Karp <samuelkarp@google.com>
Date: Tue, 26 May 2026 16:06:58 -0700
Subject: [PATCH 2/5] cri: do not re-tag restored checkpoints

Google-Bug-Id: 508657842
Signed-off-by: Samuel Karp <samuelkarp@google.com>
(cherry picked from commit 0c0918fa8fb4d997f889a3d811603995a3a2b68a)
Signed-off-by: Samuel Karp <samuelkarp@google.com>
---
.../checkpoint/checkpoint-restore-cri-test.sh | 4 ++--
.../cri/server/container_checkpoint_linux.go | 17 -----------------
2 files changed, 2 insertions(+), 19 deletions(-)

diff --git a/contrib/checkpoint/checkpoint-restore-cri-test.sh b/contrib/checkpoint/checkpoint-restore-cri-test.sh
index 54735db14..2b2a66388 100755
--- a/contrib/checkpoint/checkpoint-restore-cri-test.sh
+++ b/contrib/checkpoint/checkpoint-restore-cri-test.sh
@@ -110,7 +110,7 @@ function test_from_archive() {
fi
# Cleanup
echo "--> Cleanup images: "
- crictl rmi "${TEST_IMAGE}" | sed 's/^/----> \t/'
+ (crictl rmi "${TEST_IMAGE}" || true) | sed 's/^/----> \t/'
echo -n "--> Verifying container rootfs: "
crictl exec "$ctr_id" ls -la /root/testfile
if crictl exec "$ctr_id" ls -la /etc/motd >/dev/null 2>&1; then
@@ -184,7 +184,7 @@ function test_from_oci() {
echo "--> Cleanup images: "
../../bin/ctr -n k8s.io images rm localhost/checkpoint-image:latest | sed 's/^/----> \t/'
echo "--> Cleanup images: "
- crictl rmi "${TEST_IMAGE}" | sed 's/^/----> \t/'
+ (crictl rmi "${TEST_IMAGE}" || true) | sed 's/^/----> \t/'
echo "--> Deleting all pods: "
crictl -t 5s rmp -fa | sed 's/^/----> \t/'
SUCCESS=1
diff --git a/internal/cri/server/container_checkpoint_linux.go b/internal/cri/server/container_checkpoint_linux.go
index b54963ae8..b122c392d 100644
--- a/internal/cri/server/container_checkpoint_linux.go
+++ b/internal/cri/server/container_checkpoint_linux.go
@@ -331,23 +331,6 @@ func (c *criService) CRImportCheckpoint(
if _, err := reference.ParseAnyReference(config.RootfsImageName); err != nil {
return "", fmt.Errorf("error parsing reference: %q is not a valid repository/tag %v", config.RootfsImageName, err)
}
- tagImage, err := c.client.ImageService().Get(ctx, config.RootfsImageRef)
- if err != nil {
- return "", fmt.Errorf("failed to get checkpoint base image %s: %w", config.RootfsImageRef, err)
- }
- // Second step is to tag the image with the same tag it used to have
- // during checkpointing. For the error that the image NAME:TAG already
- // exists is ignored. It could happen that NAME:TAG now belongs to
- // another NAME@DIGEST than during checkpointing and the restore will
- // happen on another image.
- // TODO: handle if NAME:TAG points to a different NAME@DIGEST
- tagImage.Name = config.RootfsImageName
- _, err = c.client.ImageService().Create(ctx, tagImage)
- if err != nil {
- if !errdefs.IsAlreadyExists(err) {
- return "", fmt.Errorf("failed to tag checkpoint base image %s with %s: %w", config.RootfsImageRef, config.RootfsImageName, err)
- }
- }

var image imagestore.Image
for i := 1; i < 500; i++ {
--
2.54.0.1189.g8c84645362-goog

Loading
Loading