diff --git a/SPECS/moby-engine/CVE-2026-17106.patch b/SPECS/moby-engine/CVE-2026-17106.patch new file mode 100644 index 00000000000..08fc6499322 --- /dev/null +++ b/SPECS/moby-engine/CVE-2026-17106.patch @@ -0,0 +1,1404 @@ +From df55fdf3cd62b85c6f22c7b1002c3acb3481f78c Mon Sep 17 00:00:00 2001 +From: Cesar Talledo +Date: Wed, 15 Jul 2026 16:15:36 -0700 +Subject: [PATCH] archive: harden tar extraction against path traversal +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Addresses ART-224 and the cluster of externally reported tar-extraction +breakouts (Windows BuildKit ADD/build, and docker cp on all platforms). + +- Reject traversal entries instead of clamping them: normalize hdr.Name + with path.Clean(strings.TrimLeft(name, "/")) and reject non-local names + via filepath.IsLocal, in both Unpack and UnpackLayer. +- Bound extraction with os.Root (openat-based); create symlinks with + root.Symlink (target stored verbatim, so absolute targets are kept) and + hardlinks with root.Link plus a filepath.IsLocal defence-in-depth check. +- Cache the most recent parent directory fd (dirCache) so consecutive + entries in the same directory use *at(2) syscalls, amortizing os.Root's + per-call path re-evaluation. +- Resolve symlink components with fsRootPath, a straight fork of + containerd/continuity fs.RootPath (path.go + path_test.go), un-exported + and trimmed to the functions used, to ease upstream sync. +- tar header names are POSIX; convert to native paths with + filepath.FromSlash at each os.Root / filesystem boundary, and skip + entries whose name or hardlink target Windows cannot represent (":", "\"). + +archive: make lchtimes use os.Root for path resolution + +Resolve the parent directory through os.Root and perform utimensat(2) +relative to the opened directory instead of using an absolute host path. + +This preserves os.Root's path containment guarantees while still updating +the symlink itself using AT_SYMLINK_NOFOLLOW. + +createImpliedDirectories: Keep implied dirs at ImpliedDirectoryMode under umask + +createImpliedDirectories previously used user.MkdirAllAndChown, whose +setPermissions runs os.Chmod after creation, so implied parent +directories always ended up with ImpliedDirectoryMode regardless of the +process umask. + +The os.Root rewrite creates them with root.Mkdir only, which applies the +mode subject to umask: under umask 0o027 an implied directory became +0o750 instead of 0o755. + +Re-apply the mode with root.Chmod after each successful Mkdir so implied +directories keep ImpliedDirectoryMode independent of umask, matching the +prior behavior and the function's documented contract. + +Co-authored-by: Cesar Talledo +Co-authored-by: Paweł Gronowski +Co-authored-by: Sebastiaan van Stijn +Signed-off-by: Cesar Talledo +Signed-off-by: Paweł Gronowski +Signed-off-by: Sebastiaan van Stijn + +Upstream PR: https://github.com/moby/go-archive/pull/45 +Upstream commits: +- https://github.com/moby/go-archive/commit/5c7e55be051576a7bfc42299cec3e7d55fc6341f.patch +- https://github.com/moby/go-archive/commit/df55fdf3cd62b85c6f22c7b1002c3acb3481f78c.patch +- https://github.com/moby/go-archive/commit/f43ca4dfa3054c1ff02295d5f40a7cc391225ae4.patch +- https://github.com/moby/go-archive/commit/b6b52c77884e5e174b6aafbc383e26a8f0a4b085.patch +- https://github.com/moby/go-archive/commit/a450ae0d17895e598da1c8b0a6c5b89b105d48e6.patch +- https://github.com/moby/go-archive/commit/dd5ba1991b4446462438629689e0d5be97bda9fb.patch +- https://github.com/moby/go-archive/commit/51d1dd0275e73cb73e656cbf2a597303695de11f.patch +- https://github.com/moby/go-archive/commit/47e37ddcd67bb3094fe4e284b938485503e49374.patch +--- + pkg/archive/archive.go | 309 +++++++++++++++++------ + pkg/archive/archive_linux.go | 21 +- + pkg/archive/archive_test.go | 54 +++- + pkg/archive/archive_unix.go | 67 ++++- + pkg/archive/archive_windows.go | 5 +- + pkg/archive/dev_darwin.go | 17 ++ + pkg/archive/dev_freebsd.go | 20 ++ + pkg/archive/dev_unix.go | 20 ++ + pkg/archive/diff.go | 152 ++++++----- + pkg/archive/rootpath.go | 112 ++++++++ + pkg/archive/sequential_other.go | 6 + + pkg/archive/sequential_windows_go126.go | 9 + + pkg/archive/sequential_windows_pre126.go | 6 + + pkg/archive/utils_test.go | 13 +- + pkg/chrootarchive/archive_unix_test.go | 7 +- + 15 files changed, 657 insertions(+), 161 deletions(-) + create mode 100644 pkg/archive/dev_darwin.go + create mode 100644 pkg/archive/dev_freebsd.go + create mode 100644 pkg/archive/dev_unix.go + create mode 100644 pkg/archive/rootpath.go + create mode 100644 pkg/archive/sequential_other.go + create mode 100644 pkg/archive/sequential_windows_go126.go + create mode 100644 pkg/archive/sequential_windows_pre126.go + +diff --git a/pkg/archive/archive.go b/pkg/archive/archive.go +index 43133a0..af2f73a 100644 +--- a/pkg/archive/archive.go ++++ b/pkg/archive/archive.go +@@ -13,10 +13,12 @@ import ( + "io" + "os" + "os/exec" ++ pathpkg "path" + "path/filepath" + "runtime" + "strconv" + "strings" ++ "sync" + "syscall" + "time" + +@@ -92,6 +94,23 @@ func NewDefaultArchiver() *Archiver { + return &Archiver{Untar: Untar} + } + ++// isPathEscapes reports whether err is os.Root's path-containment error. ++// ++// os.Root currently returns an unexported errPathEscapes sentinel, so callers ++// cannot detect it with errors.Is. Keep the string comparison isolated here ++// until Go exports the error; see https://go.dev/issue/74640. ++func isPathEscapes(err error) bool { ++ // https://github.com/golang/go/blob/go1.26.5/src/os/file.go#L421 ++ const errPathEscapes = "path escapes from parent" ++ for err != nil { ++ if errors.Unwrap(err) == nil { ++ return err.Error() == errPathEscapes ++ } ++ err = errors.Unwrap(err) ++ } ++ return false ++} ++ + // breakoutError is used to differentiate errors related to breaking out + // When testing archive breakout in the unit tests, this error is expected + // in order for the test to pass. +@@ -511,7 +530,7 @@ func ReadSecurityXattrToTarHeader(path string, hdr *tar.Header) error { + + type tarWhiteoutConverter interface { + ConvertWrite(*tar.Header, string, os.FileInfo) (*tar.Header, error) +- ConvertRead(*tar.Header, string) (bool, error) ++ ConvertRead(*os.Root, *tar.Header, string) (bool, error) + } + + type tarAppender struct { +@@ -608,7 +627,7 @@ func (ta *tarAppender) addTarFile(path, name string) error { + // handle re-mapping container ID mappings back to host ID mappings before + // writing tar headers/files. We skip whiteout files because they were written + // by the kernel and already have proper ownership relative to the host +- if !isOverlayWhiteout && !strings.HasPrefix(filepath.Base(hdr.Name), WhiteoutPrefix) && !ta.IdentityMapping.Empty() { ++ if !isOverlayWhiteout && !strings.HasPrefix(pathpkg.Base(hdr.Name), WhiteoutPrefix) && !ta.IdentityMapping.Empty() { + fileIDPair, err := getFileUIDGID(fi.Sys()) + if err != nil { + return err +@@ -675,7 +694,10 @@ func (ta *tarAppender) addTarFile(path, name string) error { + return nil + } + +-func createTarFile(path, extractDir string, hdr *tar.Header, reader io.Reader, opts *TarOptions) error { ++// createTarFile extracts a single tar entry into the given root. dstPath is the ++// root-relative path of the entry being extracted, in native (host-separator) ++// form so it can be passed directly to os.Root methods and fsRootPath. ++func createTarFile(root *os.Root, dstPath string, hdr *tar.Header, reader io.Reader, opts *TarOptions) error { + var ( + Lchown = true + inUserns, bestEffortXattrs bool +@@ -693,20 +715,37 @@ func createTarFile(path, extractDir string, hdr *tar.Header, reader io.Reader, o + // so use hdrInfo.Mode() (they differ for e.g. setuid bits) + hdrInfo := hdr.FileInfo() + ++ // absPath is computed lazily. It is only required for xattrs and symlink timestamps. ++ // Symlinks intentionally use root.Symlink directly to preserve absolute ++ // targets; os.Root.Symlink rejects absolute targets like /usr/lib. ++ absPath := sync.OnceValues(func() (string, error) { ++ parent, err := fsRootPath(root.Name(), filepath.Dir(dstPath)) ++ if err != nil { ++ return "", err ++ } ++ return filepath.Join(parent, filepath.Base(dstPath)), nil ++ }) ++ + switch hdr.Typeflag { + case tar.TypeDir: +- // Create directory unless it exists as a directory already. +- // In that case we just want to merge the two +- if fi, err := os.Lstat(path); !(err == nil && fi.IsDir()) { +- if err := os.Mkdir(path, hdrInfo.Mode()); err != nil { ++ // Create directory unless it already exists as one; merge in that case. ++ // os.Root.Mkdir only accepts the nine least-significant permission ++ // bits; special bits (setuid, setgid, sticky) are applied afterward ++ // by handleLChmod via root.Chmod. ++ if fi, err := root.Lstat(dstPath); err != nil || !fi.IsDir() { ++ if err := root.Mkdir(dstPath, hdrInfo.Mode()&0o777); err != nil { + return err + } + } + + case tar.TypeReg: +- // Source is regular file. We use sequential file access to avoid depleting +- // the standby list on Windows. On Linux, this equates to a regular os.OpenFile. +- file, err := sequential.OpenFile(path, os.O_CREATE|os.O_WRONLY, hdrInfo.Mode()) ++ // Source is a regular file. Use os.Root.OpenFile so that all ++ // path resolution is bounded within root using openat(2) semantics. ++ // os.Root.OpenFile only accepts the nine least-significant permission ++ // bits; special bits are applied afterward by handleLChmod. ++ // We use sequential file access to avoid depleting the standby list ++ // on Windows (go1.26). On Linux, this equates to a regular os.OpenFile. ++ file, err := root.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC|windows_O_FILE_FLAG_SEQUENTIAL_SCAN, hdrInfo.Mode()&0o777) + if err != nil { + return err + } +@@ -720,39 +759,39 @@ func createTarFile(path, extractDir string, hdr *tar.Header, reader io.Reader, o + if inUserns { // cannot create devices in a userns + return nil + } +- // Handle this is an OS-specific way +- if err := handleTarTypeBlockCharFifo(hdr, path); err != nil { ++ if err := handleTarTypeBlockCharFifo(root, hdr, dstPath); err != nil { + return err + } + + case tar.TypeFifo: +- // Handle this is an OS-specific way +- if err := handleTarTypeBlockCharFifo(hdr, path); err != nil { ++ if err := handleTarTypeBlockCharFifo(root, hdr, dstPath); err != nil { + return err + } + + case tar.TypeLink: +- // #nosec G305 -- The target path is checked for path traversal. +- targetPath := filepath.Join(extractDir, hdr.Linkname) +- // check for hardlink breakout +- if !strings.HasPrefix(targetPath, extractDir) { +- return breakoutError(fmt.Errorf("invalid hardlink %q -> %q", targetPath, hdr.Linkname)) ++ // Defence in depth: root.Link's containment is limited when ++ // dest is a volume root. ++ linkname := pathpkg.Clean(hdr.Linkname) ++ if linkname == "." || !filepath.IsLocal(linkname) { ++ return breakoutError(fmt.Errorf("invalid hardlink target %q", hdr.Linkname)) + } +- if err := os.Link(targetPath, path); err != nil { ++ if err := root.Link(filepath.FromSlash(linkname), dstPath); err != nil { + return err + } + + case tar.TypeSymlink: +- // path -> hdr.Linkname = targetPath +- // e.g. /extractDir/path/to/symlink -> ../2/file = /extractDir/path/2/file +- targetPath := filepath.Join(filepath.Dir(path), hdr.Linkname) // #nosec G305 -- The target path is checked for path traversal. +- +- // the reason we don't need to check symlinks in the path (with FollowSymlinkInScope) is because +- // that symlink would first have to be created, which would be caught earlier, at this very check: +- if !strings.HasPrefix(targetPath, extractDir) { +- return breakoutError(fmt.Errorf("invalid symlink %q -> %q", path, hdr.Linkname)) +- } +- if err := os.Symlink(hdr.Linkname, path); err != nil { ++ // Symlink targets are archive data, not filesystem paths. Preserve the ++ // target verbatim rather than cleaning or converting it (filepath.FromSlash). ++ linkTarget := hdr.Linkname ++ ++ // os.Root.Symlink contains the symlink's location (newname) within ++ // root but stores the target (oldname) verbatim, so absolute targets ++ // such as /usr/lib -- common and legitimate in container images -- are ++ // preserved rather than rejected. The symlink node is therefore always ++ // created within root via openat(2) semantics, without resolving to an ++ // absolute path; containment applies when the symlink is followed, not ++ // at creation. ++ if err := root.Symlink(linkTarget, dstPath); err != nil { + return err + } + +@@ -769,12 +808,12 @@ func createTarFile(path, extractDir string, hdr *tar.Header, reader io.Reader, o + if chownOpts == nil { + chownOpts = &idtools.Identity{UID: hdr.Uid, GID: hdr.Gid} + } +- if err := os.Lchown(path, chownOpts.UID, chownOpts.GID); err != nil { ++ if err := root.Lchown(dstPath, chownOpts.UID, chownOpts.GID); err != nil { + msg := "failed to Lchown %q for UID %d, GID %d" + if errors.Is(err, syscall.EINVAL) && userns.RunningInUserNS() { + msg += " (try increasing the number of subordinate IDs in /etc/subuid and /etc/subgid)" + } +- return errors.Wrapf(err, msg, path, hdr.Uid, hdr.Gid) ++ return errors.Wrapf(err, msg, dstPath, hdr.Uid, hdr.Gid) + } + } + +@@ -784,7 +823,13 @@ func createTarFile(path, extractDir string, hdr *tar.Header, reader io.Reader, o + if !ok { + continue + } +- if err := system.Lsetxattr(path, xattr, []byte(value), 0); err != nil { ++ // os.Root has no xattr support; use the absolute path derived from ++ // the root so the path remains bounded. ++ ap, err := absPath() ++ if err != nil { ++ return err ++ } ++ if err := system.Lsetxattr(ap, xattr, []byte(value), 0); err != nil { + if bestEffortXattrs && errors.Is(err, syscall.ENOTSUP) || errors.Is(err, syscall.EPERM) { + // EPERM occurs if modifying xattrs is not allowed. This can + // happen when running in userns with restrictions (ChromeOS). +@@ -803,7 +848,7 @@ func createTarFile(path, extractDir string, hdr *tar.Header, reader io.Reader, o + + // There is no LChmod, so ignore mode for symlink. Also, this + // must happen after chown, as that can modify the file mode +- if err := handleLChmod(hdr, path, hdrInfo); err != nil { ++ if err := handleLChmod(root, dstPath, hdr, hdrInfo); err != nil { + return err + } + +@@ -815,17 +860,21 @@ func createTarFile(path, extractDir string, hdr *tar.Header, reader io.Reader, o + + // system.Chtimes doesn't support a NOFOLLOW flag atm + if hdr.Typeflag == tar.TypeLink { +- if fi, err := os.Lstat(hdr.Linkname); err == nil && (fi.Mode()&os.ModeSymlink == 0) { +- if err := system.Chtimes(path, aTime, hdr.ModTime); err != nil { ++ if fi, err := root.Lstat(filepath.FromSlash(pathpkg.Clean(hdr.Linkname))); err == nil && (fi.Mode()&os.ModeSymlink == 0) { ++ if err := root.Chtimes(dstPath, aTime, hdr.ModTime); err != nil { + return err + } + } + } else if hdr.Typeflag != tar.TypeSymlink { +- if err := system.Chtimes(path, aTime, hdr.ModTime); err != nil { ++ if err := root.Chtimes(dstPath, aTime, hdr.ModTime); err != nil { + return err + } + } else { + ts := []syscall.Timespec{timeToTimespec(aTime), timeToTimespec(hdr.ModTime)} ++ path, err := absPath() ++ if err != nil { ++ return err ++ } + if err := system.LUtimesNano(path, ts); err != nil && err != system.ErrNotSupportedPlatform { + return err + } +@@ -1079,13 +1128,27 @@ func (t *Tarballer) Do() { + } + } + ++// unpackedDir records a directory whose mtime must be restored after all ++// entries are extracted, along with the root-relative entry name used during ++// extraction. ++type unpackedDir struct { ++ hdr *tar.Header ++ name string // root-relative entry name ++} ++ + // Unpack unpacks the decompressedArchive to dest with options. + func Unpack(decompressedArchive io.Reader, dest string, options *TarOptions) error { ++ root, err := os.OpenRoot(dest) ++ if err != nil { ++ return err ++ } ++ defer func() { _ = root.Close() }() ++ + tr := tar.NewReader(decompressedArchive) + trBuf := pools.BufioReader32KPool.Get(nil) + defer pools.BufioReader32KPool.Put(trBuf) + +- var dirs []*tar.Header ++ var dirs []unpackedDir + whiteoutConverter, err := getWhiteoutConverter(options.WhiteoutFormat, options.InUserNS) + if err != nil { + return err +@@ -1109,48 +1172,49 @@ loop: + continue + } + +- // Normalize name, for safety and for a simple is-root check +- // This keeps "../" as-is, but normalizes "/../" to "/". Or Windows: +- // This keeps "..\" as-is, but normalizes "\..\" to "\". +- hdr.Name = filepath.Clean(hdr.Name) ++ // Strip a leading "/" so absolute entries stay root-relative, and ++ // normalize the POSIX tar path. Skip entries referring to the extraction ++ // root and reject paths that escape it. ++ name := pathpkg.Clean(strings.TrimLeft(hdr.Name, "/")) ++ if name == "." { ++ continue ++ } ++ if !filepath.IsLocal(name) { ++ return breakoutError(fmt.Errorf("invalid entry name %q", hdr.Name)) ++ } + + for _, exclude := range options.ExcludePatterns { +- if strings.HasPrefix(hdr.Name, exclude) { ++ if strings.HasPrefix(name, exclude) { + continue loop + } + } + +- // Ensure that the parent directory exists. +- err = createImpliedDirectories(dest, hdr, options) +- if err != nil { +- return err ++ hdr.Name = name ++ if err := unrepresentableOnWindows(hdr); err != nil { ++ log.G(context.TODO()).Warnf("Windows: ignoring entry: %v", err) ++ continue + } + +- // #nosec G305 -- The joined path is checked for path traversal. +- path := filepath.Join(dest, hdr.Name) +- rel, err := filepath.Rel(dest, path) +- if err != nil { +- return err +- } +- if strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { +- return breakoutError(fmt.Errorf("%q is outside of %q", hdr.Name, dest)) +- } ++ // path is the native (host-separator) form of the entry name, ++ // used at all filesystem boundaries (os.Root methods, fsRootPath). ++ // hdr.Name stays POSIX (forward-slash) for logical string checks. ++ path := filepath.FromSlash(hdr.Name) + + // If path exits we almost always just want to remove and replace it + // The only exception is when it is a directory *and* the file from + // the layer is also a directory. Then we want to merge them (i.e. + // just apply the metadata from the layer). +- if fi, err := os.Lstat(path); err == nil { ++ if fi, err := root.Lstat(path); err == nil { + if options.NoOverwriteDirNonDir && fi.IsDir() && hdr.Typeflag != tar.TypeDir { + // If NoOverwriteDirNonDir is true then we cannot replace + // an existing directory with a non-directory from the archive. +- return fmt.Errorf("cannot overwrite directory %q with non-directory %q", path, dest) ++ return fmt.Errorf("cannot overwrite directory %q with non-directory %q", hdr.Name, dest) + } + + if options.NoOverwriteDirNonDir && !fi.IsDir() && hdr.Typeflag == tar.TypeDir { + // If NoOverwriteDirNonDir is true then we cannot replace + // an existing non-directory with a directory from the archive. +- return fmt.Errorf("cannot overwrite non-directory %q with directory %q", path, dest) ++ return fmt.Errorf("cannot overwrite non-directory %q with directory %q", hdr.Name, dest) + } + + if fi.IsDir() && hdr.Name == "." { +@@ -1158,7 +1222,7 @@ loop: + } + + if !(fi.IsDir() && hdr.Typeflag == tar.TypeDir) { +- if err := os.RemoveAll(path); err != nil { ++ if err := root.RemoveAll(path); err != nil { + return err + } + } +@@ -1169,8 +1233,13 @@ loop: + return err + } + ++ // This must precede whiteout conversion, which may operate on the parent. ++ if err := createImpliedDirectories(root, hdr, options); err != nil { ++ return err ++ } ++ + if whiteoutConverter != nil { +- writeFile, err := whiteoutConverter.ConvertRead(hdr, path) ++ writeFile, err := whiteoutConverter.ConvertRead(root, hdr, path) + if err != nil { + return err + } +@@ -1179,24 +1248,52 @@ loop: + } + } + +- if err := createTarFile(path, dest, hdr, trBuf, options); err != nil { ++ if err := createTarFile(root, path, hdr, trBuf, options); err != nil { + return err + } + + // Directory mtimes must be handled at the end to avoid further + // file creation in them to modify the directory mtime + if hdr.Typeflag == tar.TypeDir { +- dirs = append(dirs, hdr) ++ dirs = append(dirs, unpackedDir{hdr: hdr, name: path}) + } + } + +- for _, hdr := range dirs { +- // #nosec G305 -- The header was checked for path traversal before it was appended to the dirs slice. +- path := filepath.Join(dest, hdr.Name) +- +- if err := system.Chtimes(path, hdr.AccessTime, hdr.ModTime); err != nil { ++ for _, d := range dirs { ++ fi, err := root.Lstat(d.name) ++ if err != nil { ++ if os.IsNotExist(err) { ++ continue ++ } + return err + } ++ if !fi.IsDir() { ++ continue ++ } ++ aTime := d.hdr.AccessTime ++ if aTime.Before(d.hdr.ModTime) { ++ aTime = d.hdr.ModTime ++ } ++ path, err := fsRootPath(root.Name(), d.name) ++ if err != nil { ++ return err ++ } ++ if err := system.Chtimes(path, aTime, d.hdr.ModTime); err != nil { ++ return err ++ } ++ } ++ return nil ++} ++ ++func unrepresentableOnWindows(hdr *tar.Header) error { ++ if runtime.GOOS != "windows" { ++ return nil ++ } ++ if strings.ContainsAny(hdr.Name, `:\`) { ++ return fmt.Errorf("entry name %q contains a character Windows cannot represent in a path", hdr.Name) ++ } ++ if hdr.Typeflag == tar.TypeLink && strings.ContainsAny(hdr.Linkname, `:\`) { ++ return fmt.Errorf("hardlink target %q contains a character Windows cannot represent in a path", hdr.Linkname) + } + return nil + } +@@ -1209,21 +1306,77 @@ loop: + // The caller should have performed filepath.Clean(hdr.Name), so hdr.Name will now be in the filepath format for the OS + // on which the daemon is running. This precondition is required because this function assumes a OS-specific path + // separator when checking that a path is not the root. +-func createImpliedDirectories(dest string, hdr *tar.Header, options *TarOptions) error { ++// ++// The caller must have normalized hdr.Name (no leading ".." components). ++// All directory creation is performed via root so it is bounded within the ++// destination at the OS level (openat(2) semantics), preventing escape via ++// symlinks in the destination tree. ++func createImpliedDirectories(root *os.Root, hdr *tar.Header, options *TarOptions) error { + // Not the root directory, ensure that the parent directory exists + if !strings.HasSuffix(hdr.Name, string(os.PathSeparator)) { +- parent := filepath.Dir(hdr.Name) +- parentPath := filepath.Join(dest, parent) +- if _, err := os.Lstat(parentPath); err != nil && os.IsNotExist(err) { +- // RootPair() is confined inside this loop as most cases will not require a call, so we can spend some +- // unneeded function calls in the uncommon case to encapsulate logic -- implied directories are a niche +- // usage that reduces the portability of an image. +- rootIDs := options.IDMap.RootPair() +- +- err = idtools.MkdirAllAndChownNew(parentPath, ImpliedDirectoryMode, rootIDs) ++ parent := filepath.FromSlash(pathpkg.Dir(strings.TrimSuffix(hdr.Name, "/"))) ++ // Skip when the parent is the root itself; nothing to create. ++ if parent == "." || parent == "" { ++ return nil ++ } ++ if _, err := root.Lstat(parent); err == nil { ++ return nil ++ } else if !os.IsNotExist(err) { ++ return err ++ } ++ // RootPair() is confined inside this loop as most cases will not require a call, so we can spend some ++ // unneeded function calls in the uncommon case to encapsulate logic -- implied directories are a niche ++ // usage that reduces the portability of an image. ++ rootIDs := options.IDMap.RootPair() ++ ++ // Similar to [user.MkdirAllAndChown] ++ // ++ // [user.MkdirAllAndChown]: https://pkg.go.dev/github.com/moby/sys/user#MkdirAllAndChown ++ var cur string ++ for _, c := range strings.Split(parent, string(os.PathSeparator)) { ++ if c == "" { ++ continue ++ } ++ cur = filepath.Join(cur, c) ++ if err := root.Mkdir(cur, ImpliedDirectoryMode); err != nil { ++ if !errors.Is(err, os.ErrExist) { ++ return err ++ } ++ ++ fi, err := root.Stat(cur) ++ if err != nil { ++ return err ++ } ++ if fi.IsDir() { ++ continue ++ } ++ return &os.PathError{Op: "mkdir", Path: cur, Err: syscall.ENOTDIR} ++ } ++ if options.NoLchown { ++ continue ++ } ++ // Only the successful Mkdir case is newly-created. ++ dir, err := root.Open(cur) + if err != nil { + return err + } ++ if rootIDs.UID != 0 || rootIDs.GID != 0 { ++ if err := dir.Chown(rootIDs.UID, rootIDs.GID); err != nil { ++ _ = dir.Close() ++ return err ++ } ++ } ++ // root.Mkdir applies the mode subject to the process umask, so ++ // re-apply it with Chmod to guarantee ImpliedDirectoryMode ++ // independent of umask, matching the previous MkdirAllAndChown ++ // behavior. ++ if err := dir.Chmod(ImpliedDirectoryMode); err != nil { ++ _ = dir.Close() ++ return err ++ } ++ if err := dir.Close(); err != nil { ++ return err ++ } + } + } + +diff --git a/pkg/archive/archive_linux.go b/pkg/archive/archive_linux.go +index 2c3786c..0ff48a5 100644 +--- a/pkg/archive/archive_linux.go ++++ b/pkg/archive/archive_linux.go +@@ -63,13 +63,19 @@ func (overlayWhiteoutConverter) ConvertWrite(hdr *tar.Header, path string, fi os + return + } + +-func (c overlayWhiteoutConverter) ConvertRead(hdr *tar.Header, path string) (bool, error) { ++func (c overlayWhiteoutConverter) ConvertRead(root *os.Root, hdr *tar.Header, path string) (bool, error) { + base := filepath.Base(path) + dir := filepath.Dir(path) + + // if a directory is marked as opaque by the AUFS special file, we need to translate that to overlay + if base == WhiteoutOpaqueDir { +- err := unix.Setxattr(dir, "trusted.overlay.opaque", []byte{'y'}, 0) ++ parent, err := root.Open(dir) ++ if err != nil { ++ return false, err ++ } ++ defer parent.Close() ++ ++ err = unix.Fsetxattr(int(parent.Fd()), "trusted.overlay.opaque", []byte{'y'}, 0) + if err != nil { + return false, errors.Wrapf(err, "setxattr(%q, trusted.overlay.opaque=y)", dir) + } +@@ -81,12 +87,17 @@ func (c overlayWhiteoutConverter) ConvertRead(hdr *tar.Header, path string) (boo + if strings.HasPrefix(base, WhiteoutPrefix) { + originalBase := base[len(WhiteoutPrefix):] + originalPath := filepath.Join(dir, originalBase) ++ parent, err := root.Open(dir) ++ if err != nil { ++ return false, err ++ } ++ defer parent.Close() + +- if err := unix.Mknod(originalPath, unix.S_IFCHR, 0); err != nil { ++ if err := unix.Mknodat(int(parent.Fd()), originalBase, unix.S_IFCHR, 0); err != nil { + return false, errors.Wrapf(err, "failed to mknod(%q, S_IFCHR, 0)", originalPath) + } +- if err := os.Chown(originalPath, hdr.Uid, hdr.Gid); err != nil { +- return false, err ++ if err := unix.Fchownat(int(parent.Fd()), originalBase, hdr.Uid, hdr.Gid, unix.AT_SYMLINK_NOFOLLOW); err != nil { ++ return false, &os.PathError{Op: "lchown", Path: originalPath, Err: err} + } + + // don't write the file itself +diff --git a/pkg/archive/archive_test.go b/pkg/archive/archive_test.go +index 006a576..0f63b57 100644 +--- a/pkg/archive/archive_test.go ++++ b/pkg/archive/archive_test.go +@@ -434,7 +434,7 @@ func TestUntarPathWithDestinationSrcFileAsFolder(t *testing.T) { + } + err = defaultUntarPath(tarFile, destFolder) + if err != nil { +- t.Fatalf("UntarPath should throw not throw an error if the extracted file already exists and is a folder") ++ t.Fatalf("UntarPath should not throw an error if the extracted file already exists and is a folder: %v", err) + } + } + +@@ -861,12 +861,13 @@ func TestTarWithOptions(t *testing.T) { + // Failing prevents the archives from being uncompressed during ADD + func TestTypeXGlobalHeaderDoesNotFail(t *testing.T) { + hdr := tar.Header{Typeflag: tar.TypeXGlobalHeader} +- tmpDir, err := os.MkdirTemp("", "docker-test-archive-pax-test") ++ tmpDir := t.TempDir() ++ root, err := os.OpenRoot(tmpDir) + if err != nil { + t.Fatal(err) + } +- defer os.RemoveAll(tmpDir) +- err = createTarFile(filepath.Join(tmpDir, "pax_global_header"), tmpDir, &hdr, nil, nil) ++ defer root.Close() ++ err = createTarFile(root, "pax_global_header", &hdr, nil, nil) + if err != nil { + t.Fatal(err) + } +@@ -1203,6 +1204,51 @@ func TestUntarInvalidSymlink(t *testing.T) { + } + } + ++// TestUntarSymlinkBreakout is a regression test for a tar path-traversal ++// vulnerability: a two-hop symlink chain in a malicious archive can escape ++// the extraction root at runtime while passing the static path checks that ++// guard each entry name and symlink target. Two hops are needed because a ++// direct out-of-root symlink target is already rejected by a static check in ++// createTarFile; the first hop (go_up -> "..") fools that check for the ++// second hop (escape -> "../victim") by appearing to stay within the root ++// when paths are joined as strings, while the OS resolves go_up at runtime ++// and places escape one level higher than the check assumed. ++func TestUntarSymlinkBreakout(t *testing.T) { ++ tmpdir := t.TempDir() ++ dest := filepath.Join(tmpdir, "dest") ++ victim := filepath.Join(tmpdir, "victim") ++ if err := os.Mkdir(dest, 0o755); err != nil { ++ t.Fatal(err) ++ } ++ if err := os.Mkdir(victim, 0o755); err != nil { ++ t.Fatal(err) ++ } ++ ++ buf := &bytes.Buffer{} ++ tw := tar.NewWriter(buf) ++ for _, hdr := range []*tar.Header{ ++ {Name: "inner", Typeflag: tar.TypeDir, Mode: 0o755}, ++ {Name: "inner/go_up", Typeflag: tar.TypeSymlink, Linkname: ".."}, ++ {Name: "inner/go_up/escape", Typeflag: tar.TypeSymlink, Linkname: "../victim"}, ++ {Name: "inner/go_up/escape/newfile", Typeflag: tar.TypeReg, Mode: 0o644}, ++ } { ++ if err := tw.WriteHeader(hdr); err != nil { ++ t.Fatal(err) ++ } ++ } ++ _ = tw.Close() ++ ++ // Ignore any extraction error: a breakoutError means the escape was ++ // caught; no error means the write was safely redirected within dest. ++ // NoLchown suppresses the ownership call so the test runs without root. ++ _ = Untar(buf, dest, &TarOptions{NoLchown: true}) ++ ++ // victim/newfile must not exist; its presence proves a breakout. ++ if _, err := os.Lstat(filepath.Join(victim, "newfile")); err == nil { ++ t.Fatal("archive breakout: newfile was written outside extraction root via symlink chain") ++ } ++} ++ + func TestTempArchiveCloseMultipleTimes(t *testing.T) { + reader := io.NopCloser(strings.NewReader("hello")) + tempArchive, err := NewTempArchive(reader, "") +diff --git a/pkg/archive/archive_unix.go b/pkg/archive/archive_unix.go +index ff59d01..d25c8ac 100644 +--- a/pkg/archive/archive_unix.go ++++ b/pkg/archive/archive_unix.go +@@ -6,6 +6,7 @@ import ( + "archive/tar" + "errors" + "os" ++ pathpkg "path" + "path/filepath" + "runtime" + "strings" +@@ -96,7 +97,7 @@ func getFileUIDGID(stat interface{}) (idtools.Identity, error) { + + // handleTarTypeBlockCharFifo is an OS-specific helper function used by + // createTarFile to handle the following types of header: Block; Char; Fifo +-func handleTarTypeBlockCharFifo(hdr *tar.Header, path string) error { ++func handleTarTypeBlockCharFifo(root *os.Root, hdr *tar.Header, path string) error { + mode := uint32(hdr.Mode & 0o7777) + switch hdr.Typeflag { + case tar.TypeBlock: +@@ -107,7 +108,7 @@ func handleTarTypeBlockCharFifo(hdr *tar.Header, path string) error { + mode |= unix.S_IFIFO + } + +- err := system.Mknod(path, mode, int(system.Mkdev(hdr.Devmajor, hdr.Devminor))) ++ err := mknodInRoot(root, path, mode, uint64(system.Mkdev(hdr.Devmajor, hdr.Devminor))) + if errors.Is(err, syscall.EPERM) && userns.RunningInUserNS() { + // In most cases, cannot create a device if running in user namespace + err = nil +@@ -115,17 +116,59 @@ func handleTarTypeBlockCharFifo(hdr *tar.Header, path string) error { + return err + } + +-func handleLChmod(hdr *tar.Header, path string, hdrInfo os.FileInfo) error { +- if hdr.Typeflag == tar.TypeLink { +- if fi, err := os.Lstat(hdr.Linkname); err == nil && (fi.Mode()&os.ModeSymlink == 0) { +- if err := os.Chmod(path, hdrInfo.Mode()); err != nil { +- return err +- } +- } +- } else if hdr.Typeflag != tar.TypeSymlink { +- if err := os.Chmod(path, hdrInfo.Mode()); err != nil { +- return err ++// handleLChmod applies the mode from hdrInfo to dstPath within root, skipping ++// symlinks (there is no lchmod). For hardlinks, the mode is applied only when ++// the link target is itself not a symlink. ++func handleLChmod(root *os.Root, dstPath string, hdr *tar.Header, hdrInfo os.FileInfo) error { ++ switch hdr.Typeflag { ++ case tar.TypeSymlink: ++ return nil ++ case tar.TypeLink: ++ if fi, err := root.Lstat(filepath.FromSlash(pathpkg.Clean(hdr.Linkname))); err != nil || fi.Mode()&os.ModeSymlink != 0 { ++ return nil + } ++ return chmodNoSymlink(root, dstPath, hdrInfo.Mode()) ++ default: ++ return chmodNoSymlink(root, dstPath, hdrInfo.Mode()) ++ } ++} ++ ++func chmodNoSymlink(root *os.Root, name string, mode os.FileMode) error { ++ parent, err := root.OpenFile(filepath.Dir(name), os.O_RDONLY, 0) ++ if err != nil { ++ return err ++ } ++ defer parent.Close() ++ ++ base := filepath.Base(name) ++ perm := fileModeToPerm(mode) ++ if err := unix.Fchmodat(int(parent.Fd()), base, perm, unix.AT_SYMLINK_NOFOLLOW); err == nil { ++ return nil ++ } else if !errors.Is(err, syscall.EOPNOTSUPP) && !errors.Is(err, syscall.ENOTSUP) { ++ return &os.PathError{Op: "fchmodat", Path: name, Err: err} ++ } ++ ++ fd, err := unix.Openat(int(parent.Fd()), base, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) ++ if err != nil { ++ return &os.PathError{Op: "openat", Path: name, Err: err} ++ } ++ defer unix.Close(fd) ++ if err := unix.Fchmod(fd, perm); err != nil { ++ return &os.PathError{Op: "fchmod", Path: name, Err: err} + } + return nil + } ++ ++func fileModeToPerm(mode os.FileMode) uint32 { ++ perm := uint32(mode.Perm()) ++ if mode&os.ModeSetuid != 0 { ++ perm |= unix.S_ISUID ++ } ++ if mode&os.ModeSetgid != 0 { ++ perm |= unix.S_ISGID ++ } ++ if mode&os.ModeSticky != 0 { ++ perm |= unix.S_ISVTX ++ } ++ return perm ++} +diff --git a/pkg/archive/archive_windows.go b/pkg/archive/archive_windows.go +index 09a2583..6d28bed 100644 +--- a/pkg/archive/archive_windows.go ++++ b/pkg/archive/archive_windows.go +@@ -43,11 +43,12 @@ func getInodeFromStat(stat interface{}) (inode uint64, err error) { + + // handleTarTypeBlockCharFifo is an OS-specific helper function used by + // createTarFile to handle the following types of header: Block; Char; Fifo +-func handleTarTypeBlockCharFifo(hdr *tar.Header, path string) error { ++func handleTarTypeBlockCharFifo(root *os.Root, hdr *tar.Header, path string) error { + return nil + } + +-func handleLChmod(hdr *tar.Header, path string, hdrInfo os.FileInfo) error { ++// handleLChmod is a no-op on Windows because chmod is not supported. ++func handleLChmod(root *os.Root, path string, hdr *tar.Header, hdrInfo os.FileInfo) error { + return nil + } + +diff --git a/pkg/archive/dev_darwin.go b/pkg/archive/dev_darwin.go +new file mode 100644 +index 0000000..4620099 +--- /dev/null ++++ b/pkg/archive/dev_darwin.go +@@ -0,0 +1,17 @@ ++//go:build darwin ++ ++package archive ++ ++import ( ++ "os" ++ ++ "golang.org/x/sys/unix" ++) ++ ++func mknodInRoot(root *os.Root, path string, mode uint32, dev uint64) error { ++ abs, err := fsRootPath(root.Name(), path) ++ if err != nil { ++ return err ++ } ++ return unix.Mknod(abs, mode, int(dev)) ++} +diff --git a/pkg/archive/dev_freebsd.go b/pkg/archive/dev_freebsd.go +new file mode 100644 +index 0000000..a151c69 +--- /dev/null ++++ b/pkg/archive/dev_freebsd.go +@@ -0,0 +1,20 @@ ++//go:build freebsd ++ ++package archive ++ ++import ( ++ "os" ++ "path/filepath" ++ ++ "golang.org/x/sys/unix" ++) ++ ++func mknodInRoot(root *os.Root, path string, mode uint32, dev uint64) error { ++ parent, err := root.OpenFile(filepath.Dir(path), os.O_RDONLY|unix.O_DIRECTORY, 0) ++ if err != nil { ++ return err ++ } ++ defer parent.Close() ++ ++ return unix.Mknodat(int(parent.Fd()), filepath.Base(path), mode, dev) ++} +diff --git a/pkg/archive/dev_unix.go b/pkg/archive/dev_unix.go +new file mode 100644 +index 0000000..8c1c2fe +--- /dev/null ++++ b/pkg/archive/dev_unix.go +@@ -0,0 +1,20 @@ ++//go:build !darwin && !freebsd && !windows ++ ++package archive ++ ++import ( ++ "os" ++ "path/filepath" ++ ++ "golang.org/x/sys/unix" ++) ++ ++func mknodInRoot(root *os.Root, path string, mode uint32, dev uint64) error { ++ parent, err := root.OpenFile(filepath.Dir(path), os.O_RDONLY|unix.O_DIRECTORY, 0) ++ if err != nil { ++ return err ++ } ++ defer parent.Close() ++ ++ return unix.Mknodat(int(parent.Fd()), filepath.Base(path), mode, int(dev)) ++} +diff --git a/pkg/archive/diff.go b/pkg/archive/diff.go +index 318f594..bf340da 100644 +--- a/pkg/archive/diff.go ++++ b/pkg/archive/diff.go +@@ -6,8 +6,8 @@ import ( + "fmt" + "io" + "os" ++ pathpkg "path" + "path/filepath" +- "runtime" + "strings" + + "github.com/containerd/log" +@@ -19,11 +19,19 @@ import ( + // compressed or uncompressed. + // Returns the size in bytes of the contents of the layer. + func UnpackLayer(dest string, layer io.Reader, options *TarOptions) (size int64, err error) { ++ root, err := os.OpenRoot(dest) ++ if err != nil { ++ return 0, err ++ } ++ defer root.Close() ++ + tr := tar.NewReader(layer) + trBuf := pools.BufioReader32KPool.Get(tr) + defer pools.BufioReader32KPool.Put(trBuf) + +- var dirs []*tar.Header ++ var dirs []unpackedDir ++ // unpackedPaths tracks root-relative paths already written in this layer ++ // so that the AUFS opaque-whiteout walk knows which paths to preserve. + unpackedPaths := make(map[string]struct{}) + + if options == nil { +@@ -49,34 +57,28 @@ func UnpackLayer(dest string, layer io.Reader, options *TarOptions) (size int64, + + size += hdr.Size + +- // Normalize name, for safety and for a simple is-root check +- hdr.Name = filepath.Clean(hdr.Name) ++ // Strip a leading "/" so absolute entries stay root-relative, and ++ // normalize the POSIX tar path. Skip entries referring to the extraction ++ // root and reject paths that escape it. ++ name := pathpkg.Clean(strings.TrimLeft(hdr.Name, "/")) ++ if name == "." { ++ continue ++ } ++ if !filepath.IsLocal(name) { ++ return 0, breakoutError(fmt.Errorf("invalid entry name %q", hdr.Name)) ++ } ++ hdr.Name = name + +- // Windows does not support filenames with colons in them. Ignore +- // these files. This is not a problem though (although it might +- // appear that it is). Let's suppose a client is running docker pull. +- // The daemon it points to is Windows. Would it make sense for the +- // client to be doing a docker pull Ubuntu for example (which has files +- // with colons in the name under /usr/share/man/man3)? No, absolutely +- // not as it would really only make sense that they were pulling a +- // Windows image. However, for development, it is necessary to be able +- // to pull Linux images which are in the repository. +- // +- // TODO Windows. Once the registry is aware of what images are Windows- +- // specific or Linux-specific, this warning should be changed to an error +- // to cater for the situation where someone does manage to upload a Linux +- // image but have it tagged as Windows inadvertently. +- if runtime.GOOS == "windows" { +- if strings.Contains(hdr.Name, ":") { +- log.G(context.TODO()).Warnf("Windows: Ignoring %s (is this a Linux image?)", hdr.Name) +- continue +- } ++ // Skip entries whose name (or hardlink target) Windows cannot represent. ++ if err := unrepresentableOnWindows(hdr); err != nil { ++ log.G(context.TODO()).Warnf("Windows: ignoring entry: %v", err) ++ continue + } + +- // Ensure that the parent directory exists. +- err = createImpliedDirectories(dest, hdr, options) ++ // Ensure that the validated entry's parent directory exists. ++ err = createImpliedDirectories(root, hdr, options) + if err != nil { +- return 0, err ++ return 0, fmt.Errorf("failed to create implied directories for %q: %w", hdr.Name, err) + } + + // Skip AUFS metadata dirs +@@ -85,7 +87,7 @@ func UnpackLayer(dest string, layer io.Reader, options *TarOptions) (size int64, + // We don't want this directory, but we need the files in them so that + // such hardlinks can be resolved. + if strings.HasPrefix(hdr.Name, WhiteoutLinkDir) && hdr.Typeflag == tar.TypeReg { +- basename := filepath.Base(hdr.Name) ++ basename := pathpkg.Base(hdr.Name) + aufsHardlinks[basename] = hdr + if aufsTempdir == "" { + if aufsTempdir, err = os.MkdirTemp(dest, "dockerplnk"); err != nil { +@@ -93,47 +95,63 @@ func UnpackLayer(dest string, layer io.Reader, options *TarOptions) (size int64, + } + defer os.RemoveAll(aufsTempdir) + } +- if err := createTarFile(filepath.Join(aufsTempdir, basename), dest, hdr, tr, options); err != nil { ++ aufsRoot, err := os.OpenRoot(aufsTempdir) ++ if err != nil { + return 0, err + } ++ cerr := createTarFile(aufsRoot, basename, hdr, tr, options) ++ _ = aufsRoot.Close() ++ if cerr != nil { ++ return 0, cerr ++ } + } + + if hdr.Name != WhiteoutOpaqueDir { + continue + } + } +- //#nosec G305 -- The joined path is guarded against path traversal. +- path := filepath.Join(dest, hdr.Name) +- rel, err := filepath.Rel(dest, path) +- if err != nil { +- return 0, err +- } +- +- // Note as these operations are platform specific, so must the slash be. +- if strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { +- return 0, breakoutError(fmt.Errorf("%q is outside of %q", hdr.Name, dest)) +- } ++ // path is the native (host-separator) form of the entry name, ++ // used at all filesystem boundaries (os.Root methods, fsRootPath). ++ // The tar-header name (hdr.Name) is POSIX, so convert it here. ++ path := filepath.FromSlash(hdr.Name) + base := filepath.Base(path) + + if strings.HasPrefix(base, WhiteoutPrefix) { + dir := filepath.Dir(path) + if base == WhiteoutOpaqueDir { +- _, err := os.Lstat(dir) ++ _, err := root.Lstat(dir) + if err != nil { + return 0, err + } +- err = filepath.WalkDir(dir, func(path string, info os.DirEntry, err error) error { ++ // Walk the absolute directory so we can call os.RemoveAll on ++ // paths outside the walk callback's reach, then convert each ++ // walked path back to a root-relative name for the ++ // unpackedPaths check. ++ // fsRootPath walks each path component and bounds any symlinks ++ // within the root to prevent TOCTOU symlink attacks. ++ absDir, err := fsRootPath(root.Name(), dir) ++ if err != nil { ++ return 0, err ++ } ++ err = filepath.WalkDir(absDir, func(p string, info os.DirEntry, err error) error { + if err != nil { + if os.IsNotExist(err) { +- err = nil // parent was deleted ++ return nil // parent was deleted + } + return err + } +- if path == dir { ++ if p == absDir { + return nil + } +- if _, exists := unpackedPaths[path]; !exists { +- return os.RemoveAll(path) ++ rel, err := filepath.Rel(root.Name(), p) ++ if err != nil { ++ return err ++ } ++ ++ // unpackedPaths is keyed by root-relative slash paths; convert ++ // filepath.WalkDir's native path before looking it up. ++ if _, exists := unpackedPaths[filepath.ToSlash(rel)]; !exists { ++ return root.RemoveAll(rel) + } + return nil + }) +@@ -143,7 +161,7 @@ func UnpackLayer(dest string, layer io.Reader, options *TarOptions) (size int64, + } else { + originalBase := base[len(WhiteoutPrefix):] + originalPath := filepath.Join(dir, originalBase) +- if err := os.RemoveAll(originalPath); err != nil { ++ if err := root.RemoveAll(originalPath); err != nil { + return 0, err + } + } +@@ -152,9 +170,9 @@ func UnpackLayer(dest string, layer io.Reader, options *TarOptions) (size int64, + // The only exception is when it is a directory *and* the file from + // the layer is also a directory. Then we want to merge them (i.e. + // just apply the metadata from the layer). +- if fi, err := os.Lstat(path); err == nil { ++ if fi, err := root.Lstat(path); err == nil { + if !(fi.IsDir() && hdr.Typeflag == tar.TypeDir) { +- if err := os.RemoveAll(path); err != nil { ++ if err := root.RemoveAll(path); err != nil { + return 0, err + } + } +@@ -166,8 +184,8 @@ func UnpackLayer(dest string, layer io.Reader, options *TarOptions) (size int64, + + // Hard links into /.wh..wh.plnk don't work, as we don't extract that directory, so + // we manually retarget these into the temporary files we extracted them into +- if hdr.Typeflag == tar.TypeLink && strings.HasPrefix(filepath.Clean(hdr.Linkname), WhiteoutLinkDir) { +- linkBasename := filepath.Base(hdr.Linkname) ++ if hdr.Typeflag == tar.TypeLink && strings.HasPrefix(pathpkg.Clean(hdr.Linkname), WhiteoutLinkDir) { ++ linkBasename := pathpkg.Base(hdr.Linkname) + srcHdr = aufsHardlinks[linkBasename] + if srcHdr == nil { + return 0, fmt.Errorf("Invalid aufs hardlink") +@@ -184,23 +202,41 @@ func UnpackLayer(dest string, layer io.Reader, options *TarOptions) (size int64, + return 0, err + } + +- if err := createTarFile(path, dest, srcHdr, srcData, options); err != nil { +- return 0, err ++ if err := createTarFile(root, path, srcHdr, srcData, options); err != nil { ++ return 0, fmt.Errorf("failed to create %q: %w", hdr.Name, err) + } + + // Directory mtimes must be handled at the end to avoid further + // file creation in them to modify the directory mtime + if hdr.Typeflag == tar.TypeDir { +- dirs = append(dirs, hdr) ++ dirs = append(dirs, unpackedDir{hdr: hdr, name: path}) + } +- unpackedPaths[path] = struct{}{} ++ // unpackedPaths is keyed by the POSIX (forward-slash) name so it ++ // matches the ToSlash'd lookup in the opaque-whiteout walk above. ++ unpackedPaths[hdr.Name] = struct{}{} + } + } + +- for _, hdr := range dirs { +- //#nosec G305 -- The header was checked for path traversal before it was appended to the dirs slice. +- path := filepath.Join(dest, hdr.Name) +- if err := system.Chtimes(path, hdr.AccessTime, hdr.ModTime); err != nil { ++ for _, d := range dirs { ++ fi, err := root.Lstat(d.name) ++ if err != nil { ++ if os.IsNotExist(err) { ++ continue ++ } ++ return 0, err ++ } ++ if !fi.IsDir() { ++ continue ++ } ++ aTime := d.hdr.AccessTime ++ if aTime.Before(d.hdr.ModTime) { ++ aTime = d.hdr.ModTime ++ } ++ path, err := fsRootPath(root.Name(), d.name) ++ if err != nil { ++ return 0, err ++ } ++ if err := system.Chtimes(path, aTime, d.hdr.ModTime); err != nil { + return 0, err + } + } +diff --git a/pkg/archive/rootpath.go b/pkg/archive/rootpath.go +new file mode 100644 +index 0000000..3834af2 +--- /dev/null ++++ b/pkg/archive/rootpath.go +@@ -0,0 +1,112 @@ ++/* ++ 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 archive ++ ++import ( ++ "errors" ++ "os" ++ "path/filepath" ++) ++ ++var errTooManyLinks = errors.New("too many links") ++ ++// fsRootPath joins a path with a root, evaluating and bounding any ++// symlink to the root directory. ++func fsRootPath(root, path string) (string, error) { ++ if path == "" { ++ return root, nil ++ } ++ var linksWalked int // to protect against cycles ++ for { ++ i := linksWalked ++ newpath, err := walkLinks(root, path, &linksWalked) ++ if err != nil { ++ return "", err ++ } ++ path = newpath ++ if i == linksWalked { ++ newpath = filepath.Join(string(os.PathSeparator), newpath) ++ if path == newpath { ++ return filepath.Join(root, newpath), nil ++ } ++ path = newpath ++ } ++ } ++} ++ ++func walkLink(root, path string, linksWalked *int) (newpath string, islink bool, err error) { ++ if *linksWalked > 255 { ++ return "", false, errTooManyLinks ++ } ++ ++ path = filepath.Join(string(os.PathSeparator), path) ++ if path == string(os.PathSeparator) { ++ return path, false, nil ++ } ++ realPath := filepath.Join(root, path) ++ ++ fi, err := os.Lstat(realPath) ++ if err != nil { ++ // If path does not yet exist, treat as non-symlink ++ if os.IsNotExist(err) { ++ return path, false, nil ++ } ++ return "", false, err ++ } ++ if fi.Mode()&os.ModeSymlink == 0 { ++ return path, false, nil ++ } ++ newpath, err = os.Readlink(realPath) ++ if err != nil { ++ return "", false, err ++ } ++ *linksWalked++ ++ return newpath, true, nil ++} ++ ++func walkLinks(root, path string, linksWalked *int) (string, error) { ++ switch dir, file := filepath.Split(path); { ++ case dir == "": ++ newpath, _, err := walkLink(root, file, linksWalked) ++ return newpath, err ++ case file == "": ++ if os.IsPathSeparator(dir[len(dir)-1]) { ++ if dir == string(os.PathSeparator) { ++ return dir, nil ++ } ++ return walkLinks(root, dir[:len(dir)-1], linksWalked) ++ } ++ newpath, _, err := walkLink(root, dir, linksWalked) ++ return newpath, err ++ default: ++ newdir, err := walkLinks(root, dir, linksWalked) ++ if err != nil { ++ return "", err ++ } ++ newpath, islink, err := walkLink(root, filepath.Join(newdir, file), linksWalked) ++ if err != nil { ++ return "", err ++ } ++ if !islink { ++ return newpath, nil ++ } ++ if filepath.IsAbs(newpath) { ++ return newpath, nil ++ } ++ return filepath.Join(newdir, newpath), nil ++ } ++} +diff --git a/pkg/archive/sequential_other.go b/pkg/archive/sequential_other.go +new file mode 100644 +index 0000000..90edb13 +--- /dev/null ++++ b/pkg/archive/sequential_other.go +@@ -0,0 +1,6 @@ ++//go:build !windows ++ ++package archive ++ ++// windows_O_FILE_FLAG_SEQUENTIAL_SCAN is not supported on go < 1.26. ++const windows_O_FILE_FLAG_SEQUENTIAL_SCAN = 0 +diff --git a/pkg/archive/sequential_windows_go126.go b/pkg/archive/sequential_windows_go126.go +new file mode 100644 +index 0000000..1e80d11 +--- /dev/null ++++ b/pkg/archive/sequential_windows_go126.go +@@ -0,0 +1,9 @@ ++//go:build windows && go1.26 ++ ++package archive ++ ++// windows_O_FILE_FLAG_SEQUENTIAL_SCAN matches [golang.org/x/sys/windows.O_FILE_FLAG_SEQUENTIAL_SCAN]. ++// Starting in Go 1.26, os.OpenFile supports passing this flag through. ++// ++// TODO(thaJeztah): use windows.O_FILE_FLAG_SEQUENTIAL_SCAN once we drop Go <1.26. ++const windows_O_FILE_FLAG_SEQUENTIAL_SCAN = 0x08000000 +diff --git a/pkg/archive/sequential_windows_pre126.go b/pkg/archive/sequential_windows_pre126.go +new file mode 100644 +index 0000000..2b28174 +--- /dev/null ++++ b/pkg/archive/sequential_windows_pre126.go +@@ -0,0 +1,6 @@ ++//go:build windows && !go1.26 ++ ++package archive ++ ++// windows_O_FILE_FLAG_SEQUENTIAL_SCAN is not supported on go < 1.26. ++const windows_O_FILE_FLAG_SEQUENTIAL_SCAN = 0 +diff --git a/pkg/archive/utils_test.go b/pkg/archive/utils_test.go +index 524ffc7..4c67710 100644 +--- a/pkg/archive/utils_test.go ++++ b/pkg/archive/utils_test.go +@@ -75,7 +75,7 @@ func testBreakout(untarFn string, tmpdir string, headers []*tar.Header) error { + return fmt.Errorf("could not find untar function %q in testUntarFns", untarFn) + } + if err := untar(dest, reader); err != nil { +- if _, ok := err.(breakoutError); !ok { ++ if _, ok := err.(breakoutError); !ok && !isPathEscapes(err) { + // If untar returns an error unrelated to an archive breakout, + // then consider this an unexpected error and abort. + return err +@@ -139,6 +139,12 @@ func testBreakout(untarFn string, tmpdir string, headers []*tar.Header) error { + // Since victim/hello was generated with time.Now(), it is safe to assume + // that any file whose content matches exactly victim/hello, managed somehow + // to access victim/hello. ++ // ++ // Symlinks are intentionally skipped: the os.Root security model allows ++ // extracting symlinks with targets outside the root (since the node itself ++ // is inside the root), and subsequent access through os.Root-bounded ++ // operations will catch any attempted escape. A symlink whose target ++ // resolves outside root does not constitute a breakout on its own. + return filepath.WalkDir(dest, func(path string, info os.DirEntry, err error) error { + if info.IsDir() { + if err != nil { +@@ -152,6 +158,11 @@ func testBreakout(untarFn string, tmpdir string, headers []*tar.Header) error { + // skip file if error + return nil + } ++ // Skip symlinks: their targets may point outside the root, but that ++ // is safe under the os.Root access model. ++ if info.Type()&os.ModeSymlink != 0 { ++ return nil ++ } + b, err := os.ReadFile(path) + if err != nil { + // Houston, we have a problem. Aborting (space)walk. +diff --git a/pkg/chrootarchive/archive_unix_test.go b/pkg/chrootarchive/archive_unix_test.go +index e0ab697..d725ff7 100644 +--- a/pkg/chrootarchive/archive_unix_test.go ++++ b/pkg/chrootarchive/archive_unix_test.go +@@ -59,7 +59,12 @@ func TestUntarWithMaliciousSymlinks(t *testing.T) { + + err = UntarWithRoot(tee, safe, nil, root) + assert.Assert(t, err != nil) +- assert.ErrorContains(t, err, "open /safe/host-file: no such file or directory") ++ // Bounded extraction via os.Root may fail when opening the destination ++ // itself (the symlink target lies outside the chroot root) rather than ++ // when opening the file inside it. Accept either failure point; the ++ // security property — that the host file is not overwritten — is ++ // verified separately below. ++ assert.ErrorContains(t, err, "no such file or directory") + + // Make sure the "host" file is still in tact + // Before the fix the host file would be overwritten +-- +2.45.4 + diff --git a/SPECS/moby-engine/CVE-2026-61711.patch b/SPECS/moby-engine/CVE-2026-61711.patch new file mode 100644 index 00000000000..38a8f225b32 --- /dev/null +++ b/SPECS/moby-engine/CVE-2026-61711.patch @@ -0,0 +1,89 @@ +From 3ea6dd0ce7d269cdb8aa23348718e2c1bf64f109 Mon Sep 17 00:00:00 2001 +From: Tonis Tiigi +Date: Tue, 16 Jun 2026 22:13:30 -0700 +Subject: [PATCH] security: validate exec security modes + +Reject unknown SecurityMode values before generating executor specs. Ensure +only SecurityMode_INSECURE takes the insecure path, while validated non-insecure +modes keep sandbox security options. Add gateway, raw LLB, and LLB marshal +regression coverage for invalid enum values. + +Signed-off-by: Tonis Tiigi +(cherry picked from commit 972895718f963c71388aeebb7cff423ef6963a92) + +Upstream Patch Reference: https://github.com/moby/buildkit/commit/3ea6dd0ce7d269cdb8aa23348718e2c1bf64f109.patch +--- + vendor/github.com/moby/buildkit/client/llb/exec.go | 6 +++++- + .../moby/buildkit/executor/oci/spec_unix.go | 3 +++ + .../moby/buildkit/executor/oci/spec_windows.go | 3 +++ + .../moby/buildkit/solver/pb/securitymode.go | 12 ++++++++++++ + 4 files changed, 23 insertions(+), 1 deletion(-) + create mode 100644 vendor/github.com/moby/buildkit/solver/pb/securitymode.go + +diff --git a/vendor/github.com/moby/buildkit/client/llb/exec.go b/vendor/github.com/moby/buildkit/client/llb/exec.go +index 0eed677..a09f7d2 100644 +--- a/vendor/github.com/moby/buildkit/client/llb/exec.go ++++ b/vendor/github.com/moby/buildkit/client/llb/exec.go +@@ -249,8 +249,12 @@ func (e *ExecOp) Marshal(ctx context.Context, c *Constraints) (digest.Digest, [] + addCap(&e.constraints, pb.CapExecMetaNetwork) + } + +- if security != SecurityModeSandbox { ++ switch security { ++ case SecurityModeSandbox: ++ case SecurityModeInsecure: + addCap(&e.constraints, pb.CapExecMetaSecurity) ++ default: ++ return "", nil, nil, nil, pb.ValidateSecurityMode(security) + } + + if p := e.proxyEnv; p != nil { +diff --git a/vendor/github.com/moby/buildkit/executor/oci/spec_unix.go b/vendor/github.com/moby/buildkit/executor/oci/spec_unix.go +index e38ef12..592e87f 100644 +--- a/vendor/github.com/moby/buildkit/executor/oci/spec_unix.go ++++ b/vendor/github.com/moby/buildkit/executor/oci/spec_unix.go +@@ -44,6 +44,9 @@ func generateMountOpts(resolvConf, hostsFile string) ([]oci.SpecOpts, error) { + + // generateSecurityOpts may affect mounts, so must be called after generateMountOpts + func generateSecurityOpts(mode pb.SecurityMode, apparmorProfile string, selinuxB bool) (opts []oci.SpecOpts, _ error) { ++ if err := pb.ValidateSecurityMode(mode); err != nil { ++ return nil, err ++ } + if selinuxB && !selinux.GetEnabled() { + return nil, errors.New("selinux is not available") + } +diff --git a/vendor/github.com/moby/buildkit/executor/oci/spec_windows.go b/vendor/github.com/moby/buildkit/executor/oci/spec_windows.go +index 261bbb5..0d87207 100644 +--- a/vendor/github.com/moby/buildkit/executor/oci/spec_windows.go ++++ b/vendor/github.com/moby/buildkit/executor/oci/spec_windows.go +@@ -26,6 +26,9 @@ func generateMountOpts(resolvConf, hostsFile string) ([]oci.SpecOpts, error) { + + // generateSecurityOpts may affect mounts, so must be called after generateMountOpts + func generateSecurityOpts(mode pb.SecurityMode, apparmorProfile string, selinuxB bool) ([]oci.SpecOpts, error) { ++ if err := pb.ValidateSecurityMode(mode); err != nil { ++ return nil, err ++ } + if mode == pb.SecurityMode_INSECURE { + return nil, errors.New("no support for running in insecure mode on Windows") + } +diff --git a/vendor/github.com/moby/buildkit/solver/pb/securitymode.go b/vendor/github.com/moby/buildkit/solver/pb/securitymode.go +new file mode 100644 +index 0000000..17e1836 +--- /dev/null ++++ b/vendor/github.com/moby/buildkit/solver/pb/securitymode.go +@@ -0,0 +1,12 @@ ++package pb ++ ++import "github.com/pkg/errors" ++ ++func ValidateSecurityMode(mode SecurityMode) error { ++ switch mode { ++ case SecurityMode_SANDBOX, SecurityMode_INSECURE: ++ return nil ++ default: ++ return errors.Errorf("invalid security mode %d", mode) ++ } ++} +-- +2.45.4 + diff --git a/SPECS/moby-engine/CVE-2026-61712.patch b/SPECS/moby-engine/CVE-2026-61712.patch new file mode 100644 index 00000000000..e78dcc8a8a4 --- /dev/null +++ b/SPECS/moby-engine/CVE-2026-61712.patch @@ -0,0 +1,188 @@ +From 69a3924648e485acb3faad3081e03a8554431255 Mon Sep 17 00:00:00 2001 +From: Tonis Tiigi +Date: Mon, 22 Jun 2026 12:39:26 -0700 +Subject: [PATCH] user: limit size of parsed passwd/group files + +Resolving a username to uid/gid read /etc/passwd and /etc/group via +os.Open with no upper bound, letting a crafted image force unbounded +memory use during user resolution. Cap reads at 10MiB and reject +non-regular files in both the OCI executor and the chown user resolver. + +Signed-off-by: Tonis Tiigi +(cherry picked from commit 83cfc1e0ea1dcf8816f259ee6720b8694ab874e5) + +Upstream Patch Reference: https://github.com/moby/buildkit/commit/69a3924648e485acb3faad3081e03a8554431255.patch +--- + .../moby/buildkit/executor/oci/user.go | 43 +++++++++++++- + .../solver/llbsolver/file/user_linux.go | 59 +++++++++++++++---- + 2 files changed, 87 insertions(+), 15 deletions(-) + +diff --git a/vendor/github.com/moby/buildkit/executor/oci/user.go b/vendor/github.com/moby/buildkit/executor/oci/user.go +index bb58e83..25ba11a 100644 +--- a/vendor/github.com/moby/buildkit/executor/oci/user.go ++++ b/vendor/github.com/moby/buildkit/executor/oci/user.go +@@ -2,6 +2,7 @@ package oci + + import ( + "context" ++ "io" + "os" + "strconv" + "strings" +@@ -14,6 +15,8 @@ import ( + "github.com/pkg/errors" + ) + ++const maxUserFileBytes = 10 << 20 ++ + func GetUser(root, username string) (uint32, uint32, []uint32, error) { + var isDefault bool + if username == "" { +@@ -67,12 +70,46 @@ func ParseUIDGID(str string) (uid uint32, gid uint32, err error) { + return + } + +-func openUserFile(root, p string) (*os.File, error) { ++func openUserFile(root, p string) (io.ReadCloser, error) { + p, err := fs.RootPath(root, p) + if err != nil { +- return nil, err ++ return nil, errors.WithStack(err) ++ } ++ ++ f, err := os.Open(p) ++ if err != nil { ++ return nil, errors.WithStack(err) ++ } ++ ++ info, err := f.Stat() ++ if err != nil { ++ f.Close() ++ return nil, errors.WithStack(err) ++ } ++ if !info.Mode().IsRegular() { ++ f.Close() ++ return nil, errors.Errorf("%s is not a regular file", p) ++ } ++ ++ return &limitedReadCloser{ ++ ReadCloser: f, ++ r: &io.LimitedReader{R: f, N: maxUserFileBytes + 1}, ++ name: p, ++ }, nil ++} ++ ++type limitedReadCloser struct { ++ io.ReadCloser ++ r *io.LimitedReader ++ name string ++} ++ ++func (l *limitedReadCloser) Read(p []byte) (int, error) { ++ n, err := l.r.Read(p) ++ if l.r.N == 0 { ++ return n, errors.Errorf("%q exceeds %d bytes", l.name, maxUserFileBytes) + } +- return os.Open(p) ++ return n, err + } + + func parseUID(str string) (uint32, error) { +diff --git a/vendor/github.com/moby/buildkit/solver/llbsolver/file/user_linux.go b/vendor/github.com/moby/buildkit/solver/llbsolver/file/user_linux.go +index 1f17431..b3a0123 100644 +--- a/vendor/github.com/moby/buildkit/solver/llbsolver/file/user_linux.go ++++ b/vendor/github.com/moby/buildkit/solver/llbsolver/file/user_linux.go +@@ -1,6 +1,7 @@ + package file + + import ( ++ "io" + "os" + "syscall" + +@@ -13,6 +14,8 @@ import ( + copy "github.com/tonistiigi/fsutil/copy" + ) + ++const maxUserFileBytes = 10 << 20 ++ + func readUser(chopt *pb.ChownOpt, mu, mg fileoptypes.Mount) (*copy.User, error) { + if chopt == nil { + return nil, nil +@@ -40,12 +43,7 @@ func readUser(chopt *pb.ChownOpt, mu, mg fileoptypes.Mount) (*copy.User, error) + return nil, err + } + +- passwdPath, err = fs.RootPath(dir, passwdPath) +- if err != nil { +- return nil, err +- } +- +- ufile, err := os.Open(passwdPath) ++ ufile, err := openUserFile(dir, passwdPath) + if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) { + // Couldn't open the file. Considering this case as not finding the user in the file. + break +@@ -94,12 +92,7 @@ func readUser(chopt *pb.ChownOpt, mu, mg fileoptypes.Mount) (*copy.User, error) + return nil, err + } + +- groupPath, err = fs.RootPath(dir, groupPath) +- if err != nil { +- return nil, err +- } +- +- gfile, err := os.Open(groupPath) ++ gfile, err := openUserFile(dir, groupPath) + if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) { + // Couldn't open the file. Considering this case as not finding the group in the file. + break +@@ -126,3 +119,45 @@ func readUser(chopt *pb.ChownOpt, mu, mg fileoptypes.Mount) (*copy.User, error) + + return &us, nil + } ++ ++func openUserFile(root, p string) (io.ReadCloser, error) { ++ p, err := fs.RootPath(root, p) ++ if err != nil { ++ return nil, errors.WithStack(err) ++ } ++ ++ f, err := os.Open(p) ++ if err != nil { ++ return nil, errors.WithStack(err) ++ } ++ ++ info, err := f.Stat() ++ if err != nil { ++ f.Close() ++ return nil, errors.WithStack(err) ++ } ++ if !info.Mode().IsRegular() { ++ f.Close() ++ return nil, errors.Errorf("%s is not a regular file", p) ++ } ++ ++ return &limitedReadCloser{ ++ ReadCloser: f, ++ r: &io.LimitedReader{R: f, N: maxUserFileBytes + 1}, ++ name: p, ++ }, nil ++} ++ ++type limitedReadCloser struct { ++ io.ReadCloser ++ r *io.LimitedReader ++ name string ++} ++ ++func (l *limitedReadCloser) Read(p []byte) (int, error) { ++ n, err := l.r.Read(p) ++ if l.r.N == 0 { ++ return n, errors.Errorf("%q exceeds %d bytes", l.name, maxUserFileBytes) ++ } ++ return n, err ++} +-- +2.45.4 + diff --git a/SPECS/moby-engine/CVE-2026-75593.patch b/SPECS/moby-engine/CVE-2026-75593.patch new file mode 100644 index 00000000000..574de8e7e0e --- /dev/null +++ b/SPECS/moby-engine/CVE-2026-75593.patch @@ -0,0 +1,28 @@ +From 30cd4fc5d9114b9ef5fe97aecc70583aa746558c Mon Sep 17 00:00:00 2001 +From: Tonis Tiigi +Date: Wed, 8 Jul 2026 16:06:26 -0700 +Subject: [PATCH] make sure validator handles direct .. + +Signed-off-by: Tonis Tiigi + +Upstream Patch Reference: https://github.com/tonistiigi/fsutil/commit/30cd4fc5d9114b9ef5fe97aecc70583aa746558c.patch +--- + vendor/github.com/tonistiigi/fsutil/validator.go | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/vendor/github.com/tonistiigi/fsutil/validator.go b/vendor/github.com/tonistiigi/fsutil/validator.go +index 9bd7d94..92d198d 100644 +--- a/vendor/github.com/tonistiigi/fsutil/validator.go ++++ b/vendor/github.com/tonistiigi/fsutil/validator.go +@@ -42,7 +42,7 @@ func (v *Validator) HandleChange(kind ChangeKind, p string, fi os.FileInfo, err + if dir == "." { + dir = "" + } +- if dir == ".." || strings.HasPrefix(p, "../") { ++ if p == ".." || dir == ".." || strings.HasPrefix(p, "../") { + return errors.WithStack(&os.PathError{Path: p, Err: syscall.EINVAL, Op: "escape check"}) + } + +-- +2.45.4 + diff --git a/SPECS/moby-engine/moby-engine.spec b/SPECS/moby-engine/moby-engine.spec index 0b3bf046c4c..c77337ebe09 100644 --- a/SPECS/moby-engine/moby-engine.spec +++ b/SPECS/moby-engine/moby-engine.spec @@ -3,7 +3,7 @@ Summary: The open-source application container engine Name: moby-engine Version: 25.0.3 -Release: 19%{?dist} +Release: 20%{?dist} License: ASL 2.0 Group: Tools/Container URL: https://mobyproject.org @@ -41,6 +41,10 @@ Patch22: CVE-2026-46597.patch Patch23: CVE-2026-39827.patch Patch24: CVE-2026-39835.patch Patch25: CVE-2026-56852.patch +Patch26: CVE-2026-61712.patch +Patch27: CVE-2026-75593.patch +Patch28: CVE-2026-61711.patch +Patch29: CVE-2026-17106.patch %{?systemd_requires} @@ -136,6 +140,9 @@ fi %{_unitdir}/* %changelog +* Thu Aug 27 2026 Jyoti Kanase - 25.0.3-20 +- Patch for CVE-2026-61711, CVE-2026-61712, CVE-2026-75593, CVE-2026-17106 + * Mon Jul 27 2026 Azure Linux Security Servicing Account - 25.0.3-19 - Patch for CVE-2026-56852