Version in base suite: 26.1.5+dfsg1-9 Version in overlay suite: 26.1.5+dfsg1-9 Base version: docker.io_26.1.5+dfsg1-9 Target version: docker.io_26.1.5+dfsg1-9+deb13u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/d/docker.io/docker.io_26.1.5+dfsg1-9.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/d/docker.io/docker.io_26.1.5+dfsg1-9+deb13u1.dsc changelog | 13 patches/buildkit-CVE-2026-33747-validate-container-id.patch | 137 ++++++ patches/buildkit-CVE-2026-33748-git-subdir-symlink-escape.patch | 208 +++++++++ patches/engine-CVE-2026-33997-plugin-privilege-off-by-one.patch | 174 ++++++++ patches/engine-CVE-2026-34040-authz-body-limit-4mib.patch | 34 + patches/engine-CVE-2026-34040-authz-reject-oversized-body.patch | 210 ++++++++++ patches/engine-CVE-2026-41567-decompress-before-entering-container-fs.patch | 162 +++++++ patches/engine-CVE-2026-41568-copy-symlink-escape.patch | 139 ++++++ patches/engine-CVE-2026-42306-pin-mount-target-fd.patch | 150 +++++++ patches/engine-CVE-2026-42306-resolve-in-container-symlinks.patch | 126 ++++++ patches/engine-go1.24-os-root-mkdirall.patch | 80 +++ patches/series | 10 12 files changed, 1443 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpl8yu8rk0/docker.io_26.1.5+dfsg1-9.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpl8yu8rk0/docker.io_26.1.5+dfsg1-9+deb13u1.dsc: no acceptable signature found diff -Nru docker.io-26.1.5+dfsg1/debian/changelog docker.io-26.1.5+dfsg1/debian/changelog --- docker.io-26.1.5+dfsg1/debian/changelog 2025-02-21 19:17:16.000000000 +0000 +++ docker.io-26.1.5+dfsg1/debian/changelog 2026-08-13 05:35:47.000000000 +0000 @@ -1,3 +1,16 @@ +docker.io (26.1.5+dfsg1-9+deb13u1) trixie-security; urgency=high + + * Non-maintainer upload by the Security Team. + * Engine fixes are cherry-picked from the upstream 25.0 LTS branch, which + carries official backports of all of them; BuildKit fixes are taken from + BuildKit v0.28.1 as vendored by moby/moby: CVE-2026-41568, CVE-2026-42306, + CVE-2026-34040, CVE-2026-33997, CVE-2026-33747, CVE-2026-33748. + * Add engine-go1.24-os-root-mkdirall.patch. The upstream CVE-2026-41568 fix + calls os.Root.MkdirAll, added in Go 1.25; trixie has Go 1.24, so the two + calls are replaced by an equivalent helper built on os.Root.Mkdir. + + -- Aron Xu Thu, 13 Aug 2026 13:35:47 +0800 + docker.io (26.1.5+dfsg1-9) unstable; urgency=medium * Add buildx to Recommends of cli (Closes: #1087370) diff -Nru docker.io-26.1.5+dfsg1/debian/patches/buildkit-CVE-2026-33747-validate-container-id.patch docker.io-26.1.5+dfsg1/debian/patches/buildkit-CVE-2026-33747-validate-container-id.patch --- docker.io-26.1.5+dfsg1/debian/patches/buildkit-CVE-2026-33747-validate-container-id.patch 1970-01-01 00:00:00.000000000 +0000 +++ docker.io-26.1.5+dfsg1/debian/patches/buildkit-CVE-2026-33747-validate-container-id.patch 2026-08-13 05:35:47.000000000 +0000 @@ -0,0 +1,137 @@ +Description: executor: validate container IDs centrally + BuildKit's gateway API lets a client (e.g. a custom "#syntax" frontend) supply + the container ID that the runc/containerd executor uses. The ID was passed + unvalidated into filepath.Join(w.root, id) to build the OCI bundle path, so an + ID containing path separators or ".." could direct bundle creation outside the + executor root. + . + Add executor.ValidContainerID(), restricting IDs to ASCII letters and digits, + and call it at the top of Run() in both the runc and containerd executors + (moving the "generate an ID if empty" step ahead of the check in the runc + executor so generated IDs are validated too). + . + Per the advisory this is only reachable when a custom BuildKit frontend is in + use (via "#syntax" or --build-arg BUILDKIT_SYNTAX); the well-known + docker/dockerfile frontend is not affected. +Origin: upstream, extracted from https://github.com/moby/moby/commit/830ddb26a2e437818ffe60fbd28bcb419617df04 +Forwarded: not-needed +Last-Update: 2026-08-13 + +--- a/buildkit/executor/containerdexecutor/executor.go ++++ b/buildkit/executor/containerdexecutor/executor.go +@@ -95,6 +95,9 @@ func (w *containerdExecutor) Run(ctx con + if id == "" { + id = identity.NewID() + } ++ if err := executor.ValidContainerID(id); err != nil { ++ return nil, err ++ } + + startedOnce := sync.Once{} + done := make(chan error, 1) +--- /dev/null ++++ b/buildkit/executor/containerid.go +@@ -0,0 +1,18 @@ ++package executor ++ ++import "github.com/pkg/errors" ++ ++// ValidContainerID validates that id is non-empty and contains only ASCII letters and digits. ++func ValidContainerID(id string) error { ++ if id == "" { ++ return errors.New("container id must not be empty") ++ } ++ for i := range len(id) { ++ ch := id[i] ++ if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') { ++ continue ++ } ++ return errors.Errorf("invalid container id %q: only letters and numbers are allowed", id) ++ } ++ return nil ++} +--- a/buildkit/executor/runcexecutor/executor.go ++++ b/buildkit/executor/runcexecutor/executor.go +@@ -146,6 +146,13 @@ func New(opt Opt, networkProviders map[p + } + + func (w *runcExecutor) Run(ctx context.Context, id string, root executor.Mount, mounts []executor.Mount, process executor.ProcessInfo, started chan<- struct{}) (rec resourcestypes.Recorder, err error) { ++ if id == "" { ++ id = identity.NewID() ++ } ++ if err := executor.ValidContainerID(id); err != nil { ++ return nil, err ++ } ++ + startedOnce := sync.Once{} + done := make(chan error, 1) + w.mu.Lock() +@@ -210,9 +217,6 @@ func (w *runcExecutor) Run(ctx context.C + defer release() + } + +- if id == "" { +- id = identity.NewID() +- } + bundle := filepath.Join(w.root, id) + + if err := os.Mkdir(bundle, 0o711); err != nil { +--- a/engine/vendor/github.com/moby/buildkit/executor/containerdexecutor/executor.go ++++ b/engine/vendor/github.com/moby/buildkit/executor/containerdexecutor/executor.go +@@ -95,6 +95,9 @@ func (w *containerdExecutor) Run(ctx con + if id == "" { + id = identity.NewID() + } ++ if err := executor.ValidContainerID(id); err != nil { ++ return nil, err ++ } + + startedOnce := sync.Once{} + done := make(chan error, 1) +--- /dev/null ++++ b/engine/vendor/github.com/moby/buildkit/executor/containerid.go +@@ -0,0 +1,18 @@ ++package executor ++ ++import "github.com/pkg/errors" ++ ++// ValidContainerID validates that id is non-empty and contains only ASCII letters and digits. ++func ValidContainerID(id string) error { ++ if id == "" { ++ return errors.New("container id must not be empty") ++ } ++ for i := range len(id) { ++ ch := id[i] ++ if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') { ++ continue ++ } ++ return errors.Errorf("invalid container id %q: only letters and numbers are allowed", id) ++ } ++ return nil ++} +--- a/engine/vendor/github.com/moby/buildkit/executor/runcexecutor/executor.go ++++ b/engine/vendor/github.com/moby/buildkit/executor/runcexecutor/executor.go +@@ -146,6 +146,13 @@ func New(opt Opt, networkProviders map[p + } + + func (w *runcExecutor) Run(ctx context.Context, id string, root executor.Mount, mounts []executor.Mount, process executor.ProcessInfo, started chan<- struct{}) (rec resourcestypes.Recorder, err error) { ++ if id == "" { ++ id = identity.NewID() ++ } ++ if err := executor.ValidContainerID(id); err != nil { ++ return nil, err ++ } ++ + startedOnce := sync.Once{} + done := make(chan error, 1) + w.mu.Lock() +@@ -210,9 +217,6 @@ func (w *runcExecutor) Run(ctx context.C + defer release() + } + +- if id == "" { +- id = identity.NewID() +- } + bundle := filepath.Join(w.root, id) + + if err := os.Mkdir(bundle, 0o711); err != nil { diff -Nru docker.io-26.1.5+dfsg1/debian/patches/buildkit-CVE-2026-33748-git-subdir-symlink-escape.patch docker.io-26.1.5+dfsg1/debian/patches/buildkit-CVE-2026-33748-git-subdir-symlink-escape.patch --- docker.io-26.1.5+dfsg1/debian/patches/buildkit-CVE-2026-33748-git-subdir-symlink-escape.patch 1970-01-01 00:00:00.000000000 +0000 +++ docker.io-26.1.5+dfsg1/debian/patches/buildkit-CVE-2026-33748-git-subdir-symlink-escape.patch 2026-08-13 05:35:47.000000000 +0000 @@ -0,0 +1,208 @@ +Description: git: normalize and validate subdir paths + BuildKit's Git source handler accepts a "#ref:subdir" fragment on a Git build + context URL. Before this fix the subdir was only run through path.Clean() and + then opened with os.Open(filepath.Join(checkoutDir, subdir)), with no check + that each path segment is a real directory inside the checkout. A malicious + repository could therefore make the subdir (or an intermediate segment) a + symlink and have BuildKit read files from outside the checked-out repository. + . + This is reachable from "docker build #:" via dockerd's + embedded BuildKit builder; a standalone buildkitd is not required. +Origin: backport, derived from https://github.com/moby/moby/commit/830ddb26a2e437818ffe60fbd28bcb419617df04 +Forwarded: not-needed +Last-Update: 2026-08-13 + +--- a/buildkit/source/git/identifier.go ++++ b/buildkit/source/git/identifier.go +@@ -1,8 +1,6 @@ + package git + + import ( +- "path" +- + "github.com/moby/buildkit/solver/llbsolver/provenance" + provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types" + "github.com/moby/buildkit/source" +@@ -35,9 +33,6 @@ func NewGitIdentifier(remoteURL string) + repo.Ref = u.Fragment.Ref + repo.Subdir = u.Fragment.Subdir + } +- if sd := path.Clean(repo.Subdir); sd == "/" || sd == "." { +- repo.Subdir = "" +- } + return &repo, nil + } + +--- a/buildkit/source/git/source.go ++++ b/buildkit/source/git/source.go +@@ -519,7 +519,7 @@ func (gs *gitSourceHandler) Snapshot(ctx + } + }() + +- subdir := path.Clean(gs.src.Subdir) ++ subdir := path.Join("/", gs.src.Subdir) + if subdir == "/" { + subdir = "." + } +@@ -594,6 +594,11 @@ func (gs *gitSourceHandler) Snapshot(ctx + return nil, errors.Wrapf(err, "failed to checkout remote %s", urlutil.RedactCredentials(gs.src.Remote)) + } + if subdir != "." { ++ subdir = filepath.FromSlash(subdir) ++ if err := validateDirsOnly(cd, subdir); err != nil { ++ return nil, errors.Wrapf(err, "invalid subdir %v", subdir) ++ } ++ + d, err := os.Open(filepath.Join(cd, subdir)) + if err != nil { + return nil, errors.Wrapf(err, "failed to open subdir %v", subdir) +@@ -787,3 +792,33 @@ func gitCLI(opts ...gitutil.Option) *git + }, opts...) + return gitutil.NewGitCLI(opts...) + } ++ ++// validateDirsOnly checks that the given subpath in the repository ++// only contains directories without any symlinks or files. ++func validateDirsOnly(root string, subpath string) error { ++ rel := filepath.Clean(subpath) ++ rel = strings.TrimPrefix(rel, string(filepath.Separator)) ++ if rel == "" || rel == "." { ++ return nil ++ } ++ ++ r, err := os.OpenRoot(root) ++ if err != nil { ++ return errors.Wrapf(err, "failed to open root %q", root) ++ } ++ defer r.Close() ++ ++ p := "" ++ for _, part := range strings.Split(rel, string(filepath.Separator)) { ++ p = filepath.Join(p, part) ++ ++ fi, err := r.Lstat(p) ++ if err != nil { ++ return errors.Wrapf(err, "failed to lstat %q", p) ++ } ++ if !fi.IsDir() { ++ return errors.Errorf("git subpath %q contains non-directory %q", subpath, p) ++ } ++ } ++ return nil ++} +--- a/buildkit/util/gitutil/git_url.go ++++ b/buildkit/util/gitutil/git_url.go +@@ -2,6 +2,7 @@ package gitutil + + import ( + "net/url" ++ "path" + "regexp" + "strings" + +@@ -71,6 +72,8 @@ func splitGitFragment(fragment string) * + return nil + } + ref, subdir, _ := strings.Cut(fragment, ":") ++ subdir = path.Join("/", subdir) ++ subdir = strings.TrimPrefix(subdir, "/") + return &GitURLFragment{Ref: ref, Subdir: subdir} + } + +--- a/engine/vendor/github.com/moby/buildkit/source/git/identifier.go ++++ b/engine/vendor/github.com/moby/buildkit/source/git/identifier.go +@@ -1,8 +1,6 @@ + package git + + import ( +- "path" +- + "github.com/moby/buildkit/solver/llbsolver/provenance" + provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types" + "github.com/moby/buildkit/source" +@@ -35,9 +33,6 @@ func NewGitIdentifier(remoteURL string) + repo.Ref = u.Fragment.Ref + repo.Subdir = u.Fragment.Subdir + } +- if sd := path.Clean(repo.Subdir); sd == "/" || sd == "." { +- repo.Subdir = "" +- } + return &repo, nil + } + +--- a/engine/vendor/github.com/moby/buildkit/source/git/source.go ++++ b/engine/vendor/github.com/moby/buildkit/source/git/source.go +@@ -519,7 +519,7 @@ func (gs *gitSourceHandler) Snapshot(ctx + } + }() + +- subdir := path.Clean(gs.src.Subdir) ++ subdir := path.Join("/", gs.src.Subdir) + if subdir == "/" { + subdir = "." + } +@@ -594,6 +594,11 @@ func (gs *gitSourceHandler) Snapshot(ctx + return nil, errors.Wrapf(err, "failed to checkout remote %s", urlutil.RedactCredentials(gs.src.Remote)) + } + if subdir != "." { ++ subdir = filepath.FromSlash(subdir) ++ if err := validateDirsOnly(cd, subdir); err != nil { ++ return nil, errors.Wrapf(err, "invalid subdir %v", subdir) ++ } ++ + d, err := os.Open(filepath.Join(cd, subdir)) + if err != nil { + return nil, errors.Wrapf(err, "failed to open subdir %v", subdir) +@@ -787,3 +792,33 @@ func gitCLI(opts ...gitutil.Option) *git + }, opts...) + return gitutil.NewGitCLI(opts...) + } ++ ++// validateDirsOnly checks that the given subpath in the repository ++// only contains directories without any symlinks or files. ++func validateDirsOnly(root string, subpath string) error { ++ rel := filepath.Clean(subpath) ++ rel = strings.TrimPrefix(rel, string(filepath.Separator)) ++ if rel == "" || rel == "." { ++ return nil ++ } ++ ++ r, err := os.OpenRoot(root) ++ if err != nil { ++ return errors.Wrapf(err, "failed to open root %q", root) ++ } ++ defer r.Close() ++ ++ p := "" ++ for _, part := range strings.Split(rel, string(filepath.Separator)) { ++ p = filepath.Join(p, part) ++ ++ fi, err := r.Lstat(p) ++ if err != nil { ++ return errors.Wrapf(err, "failed to lstat %q", p) ++ } ++ if !fi.IsDir() { ++ return errors.Errorf("git subpath %q contains non-directory %q", subpath, p) ++ } ++ } ++ return nil ++} +--- a/engine/vendor/github.com/moby/buildkit/util/gitutil/git_url.go ++++ b/engine/vendor/github.com/moby/buildkit/util/gitutil/git_url.go +@@ -2,6 +2,7 @@ package gitutil + + import ( + "net/url" ++ "path" + "regexp" + "strings" + +@@ -71,6 +72,8 @@ func splitGitFragment(fragment string) * + return nil + } + ref, subdir, _ := strings.Cut(fragment, ":") ++ subdir = path.Join("/", subdir) ++ subdir = strings.TrimPrefix(subdir, "/") + return &GitURLFragment{Ref: ref, Subdir: subdir} + } + diff -Nru docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-33997-plugin-privilege-off-by-one.patch docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-33997-plugin-privilege-off-by-one.patch --- docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-33997-plugin-privilege-off-by-one.patch 1970-01-01 00:00:00.000000000 +0000 +++ docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-33997-plugin-privilege-off-by-one.patch 2026-08-13 05:21:46.000000000 +0000 @@ -0,0 +1,174 @@ +From d5986c0430124d82ce87b439638d8ebb16916e40 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= +Date: Thu, 19 Mar 2026 19:18:23 +0100 +Subject: [PATCH] plugin: Fix off-by-one in privilege validation +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Fix an off-by-one error in isEqual() where the comparison loop started +at index 1 instead of 0, causing the first privilege (after sorting +alphabetically by name) to never be validated. + +This allowed a malicious plugin to request different values for +whichever privilege sorts first — most notably "allow-all-devices", +which grants unrestricted rwm access to all host devices. + +The bug also meant that plugins requesting exactly one privilege had +zero iterations of the comparison loop, bypassing validation entirely. + +Also fix an existing test case ("diff-order-but-same-value") that only +passed due to the off-by-one bug, and add test cases for single-element +and first-sorted-element mismatches. + +Signed-off-by: Paweł Gronowski +(cherry picked from commit 99a095ecf04e8849318f2811bb3f687905eab09b) +Signed-off-by: Paweł Gronowski +--- + plugin/manager.go | 50 ++++++++++++++++++++++++------------------ + plugin/manager_test.go | 44 +++++++++++++++++++++++++++++++++---- + 2 files changed, 69 insertions(+), 25 deletions(-) + +diff --git a/engine/plugin/manager.go b/engine/plugin/manager.go +index 81f3d67b80..d98b968c7d 100644 +--- a/engine/plugin/manager.go ++++ b/engine/plugin/manager.go +@@ -1,14 +1,14 @@ + package plugin // import "github.com/docker/docker/plugin" + + import ( ++ "cmp" + "context" + "encoding/json" + "io" + "os" + "path/filepath" +- "reflect" + "regexp" +- "sort" ++ "slices" + "strings" + "sync" + "syscall" +@@ -331,34 +331,42 @@ func makeLoggerStreams(id string) (stdout, stderr io.WriteCloser) { + } + + func validatePrivileges(requiredPrivileges, privileges types.PluginPrivileges) error { +- if !isEqual(requiredPrivileges, privileges, isEqualPrivilege) { ++ if len(requiredPrivileges) != len(privileges) { + return errors.New("incorrect privileges") + } + +- return nil +-} +- +-func isEqual(arrOne, arrOther types.PluginPrivileges, compare func(x, y types.PluginPrivilege) bool) bool { +- if len(arrOne) != len(arrOther) { +- return false +- } +- +- sort.Sort(arrOne) +- sort.Sort(arrOther) ++ a := normalizePrivileges(requiredPrivileges) ++ b := normalizePrivileges(privileges) + +- for i := 1; i < arrOne.Len(); i++ { +- if !compare(arrOne[i], arrOther[i]) { +- return false ++ for i := range a { ++ if a[i].Name != b[i].Name { ++ return errors.New("incorrect privileges") ++ } ++ if !slices.Equal(a[i].Value, b[i].Value) { ++ return errors.New("incorrect privileges") + } + } + +- return true ++ return nil + } + +-func isEqualPrivilege(a, b types.PluginPrivilege) bool { +- if a.Name != b.Name { +- return false ++// normalizePrivileges returns a normalized copy of privileges with privilege names ++// and each privilege's values sorted for order-insensitive comparison. ++// The input is not mutated. ++func normalizePrivileges(privileges types.PluginPrivileges) types.PluginPrivileges { ++ normalized := make(types.PluginPrivileges, len(privileges)) ++ for i, privilege := range privileges { ++ normalized[i] = types.PluginPrivilege{ ++ Name: privilege.Name, ++ Description: privilege.Description, ++ Value: slices.Clone(privilege.Value), ++ } ++ slices.Sort(normalized[i].Value) + } + +- return reflect.DeepEqual(a.Value, b.Value) ++ slices.SortFunc(normalized, func(a, b types.PluginPrivilege) int { ++ return cmp.Compare(a.Name, b.Name) ++ }) ++ ++ return normalized + } +diff --git a/engine/plugin/manager_test.go b/engine/plugin/manager_test.go +index 62ccf2149d..6d58865044 100644 +--- a/engine/plugin/manager_test.go ++++ b/engine/plugin/manager_test.go +@@ -44,12 +44,48 @@ func TestValidatePrivileges(t *testing.T) { + }, + result: true, + }, ++ "single-element-same": { ++ requiredPrivileges: []types.PluginPrivilege{ ++ {Name: "allow-all-devices", Description: "Description", Value: []string{"true"}}, ++ }, ++ privileges: []types.PluginPrivilege{ ++ {Name: "allow-all-devices", Description: "Description", Value: []string{"true"}}, ++ }, ++ result: true, ++ }, ++ "single-element-diff-value": { ++ requiredPrivileges: []types.PluginPrivilege{ ++ {Name: "allow-all-devices", Description: "Description", Value: []string{"false"}}, ++ }, ++ privileges: []types.PluginPrivilege{ ++ {Name: "allow-all-devices", Description: "Description", Value: []string{"true"}}, ++ }, ++ result: false, ++ }, ++ "first-sorted-element-diff-value": { ++ requiredPrivileges: []types.PluginPrivilege{ ++ {Name: "allow-all-devices", Description: "Description", Value: []string{"false"}}, ++ {Name: "network", Description: "Description", Value: []string{"host"}}, ++ }, ++ privileges: []types.PluginPrivilege{ ++ {Name: "allow-all-devices", Description: "Description", Value: []string{"true"}}, ++ {Name: "network", Description: "Description", Value: []string{"host"}}, ++ }, ++ result: false, ++ }, ++ "empty-privileges": { ++ requiredPrivileges: []types.PluginPrivilege{}, ++ privileges: []types.PluginPrivilege{}, ++ result: true, ++ }, + } + + for key, data := range testData { +- err := validatePrivileges(data.requiredPrivileges, data.privileges) +- if (err == nil) != data.result { +- t.Fatalf("Test item %s expected result to be %t, got %t", key, data.result, (err == nil)) +- } ++ t.Run(key, func(t *testing.T) { ++ err := validatePrivileges(data.requiredPrivileges, data.privileges) ++ if (err == nil) != data.result { ++ t.Fatalf("expected result to be %t, got %t", data.result, (err == nil)) ++ } ++ }) + } + } +-- +2.47.3 + diff -Nru docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-34040-authz-body-limit-4mib.patch docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-34040-authz-body-limit-4mib.patch --- docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-34040-authz-body-limit-4mib.patch 1970-01-01 00:00:00.000000000 +0000 +++ docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-34040-authz-body-limit-4mib.patch 2026-08-13 05:21:45.000000000 +0000 @@ -0,0 +1,34 @@ +From 4d0135c2d25b89ecc62a15277f20177150195695 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= +Date: Mon, 16 Feb 2026 14:16:06 +0100 +Subject: [PATCH] pkg/authz: Increase body limit to 4 MiB +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Some endpoint could potentially use a body request than 1 MiB without +malicious intent. + +Signed-off-by: Paweł Gronowski +(cherry picked from commit ec76e941838797fc762185c556c152f0a032d387) +Signed-off-by: Paweł Gronowski +--- + pkg/authorization/authz.go | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/engine/pkg/authorization/authz.go b/engine/pkg/authorization/authz.go +index 3bc30f61cf..e9221307a7 100644 +--- a/engine/pkg/authorization/authz.go ++++ b/engine/pkg/authorization/authz.go +@@ -16,7 +16,7 @@ + "github.com/docker/docker/pkg/ioutils" + ) + +-const maxBodySize = 1048576 // 1MB ++const maxBodySize = 4 * 1024 * 1024 // 4MiB + + // NewCtx creates new authZ context, it is used to store authorization information related to a specific docker + // REST http session +-- +2.47.3 + diff -Nru docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-34040-authz-reject-oversized-body.patch docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-34040-authz-reject-oversized-body.patch --- docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-34040-authz-reject-oversized-body.patch 1970-01-01 00:00:00.000000000 +0000 +++ docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-34040-authz-reject-oversized-body.patch 2026-08-13 05:21:45.000000000 +0000 @@ -0,0 +1,210 @@ +From 553e8214614ee0d65ee309f148a8e865634cc291 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= +Date: Mon, 16 Feb 2026 14:15:24 +0100 +Subject: [PATCH] pkg/authz: Reject requests exceeding body size limit +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Previously, the authorization system would silently skip body inspection when +request bodies exceeded the maximum size limit (1MiB). + +The authorization plugins would receive an empty body for inspection +while the actual large payload would still be processed by the Docker +daemon, allowing malicious requests to circumvent plugin-based security +controls. + +Signed-off-by: Paweł Gronowski +(cherry picked from commit 7a767b27fd1238c89a5cc926c39e27d3bcf58e35) +Signed-off-by: Paweł Gronowski +--- + pkg/authorization/authz.go | 56 ++++++----------- + pkg/authorization/authz_unix_test.go | 93 +++++++++++++++++++--------- + 2 files changed, 83 insertions(+), 66 deletions(-) + +diff --git a/engine/pkg/authorization/authz.go b/engine/pkg/authorization/authz.go +index d568a2b597..3bc30f61cf 100644 +--- a/engine/pkg/authorization/authz.go ++++ b/engine/pkg/authorization/authz.go +@@ -55,28 +55,31 @@ type Ctx struct { + authReq *Request + } + +-func isChunked(r *http.Request) bool { +- // RFC 7230 specifies that content length is to be ignored if Transfer-Encoding is chunked +- if strings.EqualFold(r.Header.Get("Transfer-Encoding"), "chunked") { +- return true +- } +- for _, v := range r.TransferEncoding { +- if strings.EqualFold(v, "chunked") { +- return true +- } +- } +- return false +-} +- + // AuthZRequest authorized the request to the docker daemon using authZ plugins + func (ctx *Ctx) AuthZRequest(w http.ResponseWriter, r *http.Request) error { + var body []byte +- if sendBody(ctx.requestURI, r.Header) && (r.ContentLength > 0 || isChunked(r)) && r.ContentLength < maxBodySize { +- var err error +- body, r.Body, err = drainBody(r.Body) +- if err != nil { ++ if sendBody(ctx.requestURI, r.Header) { ++ // Wrap the original request body in a buffered reader so we can inspect ++ // the prefix without consuming bytes from the downstream reader. ++ // `Peek(maxBodySize + 1)` is used as a size check: ++ // - err == nil means at least maxBodySize+1 bytes are buffered/available, ++ // so the payload exceeds the plugin limit and is rejected. ++ // - otherwise, `peeked` contains the complete body bytes currently available ++ // (for short bodies this is the full payload), and reads from r.Body still ++ // stream the original body unchanged. ++ bufBody := bufio.NewReaderSize(r.Body, maxBodySize+1) ++ r.Body = ioutils.NewReadCloserWrapper(bufBody, r.Body.Close) ++ ++ peeked, err := bufBody.Peek(maxBodySize + 1) ++ if err == nil { ++ // Successfully peeked maxBodySize+1 bytes, so body is too large ++ // TODO: Allows plugin to opt in ++ return fmt.Errorf("request body too large for authorization plugin: size exceeds %d bytes", maxBodySize) ++ } else if err != io.EOF { + return err + } ++ ++ body = peeked + } + + var h bytes.Buffer +@@ -142,25 +145,6 @@ func (ctx *Ctx) AuthZResponse(rm ResponseModifier, r *http.Request) error { + return nil + } + +-// drainBody dump the body (if its length is less than 1MB) without modifying the request state +-func drainBody(body io.ReadCloser) ([]byte, io.ReadCloser, error) { +- bufReader := bufio.NewReaderSize(body, maxBodySize) +- newBody := ioutils.NewReadCloserWrapper(bufReader, func() error { return body.Close() }) +- +- data, err := bufReader.Peek(maxBodySize) +- // Body size exceeds max body size +- if err == nil { +- log.G(context.TODO()).Warnf("Request body is larger than: '%d' skipping body", maxBodySize) +- return nil, newBody, nil +- } +- // Body size is less than maximum size +- if err == io.EOF { +- return data, newBody, nil +- } +- // Unknown error +- return nil, newBody, err +-} +- + func isAuthEndpoint(urlPath string) (bool, error) { + // eg www.test.com/v1.24/auth/optional?optional1=something&optional2=something (version optional) + matched, err := regexp.MatchString(`^[^\/]*\/(v\d[\d\.]*\/)?auth.*`, urlPath) +diff --git a/engine/pkg/authorization/authz_unix_test.go b/engine/pkg/authorization/authz_unix_test.go +index 66b4d20452..c726e5f79a 100644 +--- a/engine/pkg/authorization/authz_unix_test.go ++++ b/engine/pkg/authorization/authz_unix_test.go +@@ -139,36 +139,69 @@ func TestResponseModifier(t *testing.T) { + } + } + +-func TestDrainBody(t *testing.T) { +- tests := []struct { +- length int // length is the message length send to drainBody +- expectedBodyLength int // expectedBodyLength is the expected body length after drainBody is called +- }{ +- {10, 10}, // Small message size +- {maxBodySize - 1, maxBodySize - 1}, // Max message size +- {maxBodySize * 2, 0}, // Large message size (skip copying body) +- +- } +- +- for _, test := range tests { +- msg := strings.Repeat("a", test.length) +- body, closer, err := drainBody(io.NopCloser(bytes.NewReader([]byte(msg)))) +- if err != nil { +- t.Fatal(err) +- } +- if len(body) != test.expectedBodyLength { +- t.Fatalf("Body must be copied, actual length: '%d'", len(body)) +- } +- if closer == nil { +- t.Fatal("Closer must not be nil") +- } +- modified, err := io.ReadAll(closer) +- if err != nil { +- t.Fatalf("Error must not be nil: '%v'", err) +- } +- if len(modified) != len(msg) { +- t.Fatalf("Result should not be truncated. Original length: '%d', new length: '%d'", len(msg), len(modified)) +- } ++type recordingPlugin struct { ++ recordedRequest Request ++} ++ ++func (p *recordingPlugin) Name() string { return "recording-plugin" } ++ ++func (p *recordingPlugin) AuthZRequest(authReq *Request) (*Response, error) { ++ p.recordedRequest = *authReq ++ p.recordedRequest.RequestBody = bytes.Clone(authReq.RequestBody) ++ return &Response{Allow: true}, nil ++} ++ ++func (p *recordingPlugin) AuthZResponse(_ *Request) (*Response, error) { ++ return &Response{Allow: true}, nil ++} ++ ++func TestAuthZRequestBodyWithinLimit(t *testing.T) { ++ payload := strings.Repeat("a", maxBodySize) ++ plugin := &recordingPlugin{} ++ ctx := NewCtx([]Plugin{plugin}, "user", "tls", http.MethodPost, "/containers/create") ++ ++ req := httptest.NewRequest(http.MethodPost, "http://example.com/containers/create", strings.NewReader(payload)) ++ req.Header.Set("Content-Type", "application/json") ++ ++ if err := ctx.AuthZRequest(httptest.NewRecorder(), req); err != nil { ++ t.Fatalf("AuthZRequest failed: %v", err) ++ } ++ ++ if string(plugin.recordedRequest.RequestBody) != payload { ++ t.Fatalf("expected full request body to be sent to plugin, got length %d, expected %d", len(plugin.recordedRequest.RequestBody), len(payload)) ++ } ++ ++ remaining, err := io.ReadAll(req.Body) ++ if err != nil { ++ t.Fatalf("failed to read request body after authz: %v", err) ++ } ++ if string(remaining) != payload { ++ t.Fatalf("request body should be preserved for downstream readers") ++ } ++} ++ ++func TestAuthZRequestBodyOverLimit(t *testing.T) { ++ payload := strings.Repeat("a", maxBodySize+1) ++ plugin := &recordingPlugin{} ++ ctx := NewCtx([]Plugin{plugin}, "user", "tls", http.MethodPost, "/containers/create") ++ ++ req := httptest.NewRequest(http.MethodPost, "http://example.com/containers/create", strings.NewReader(payload)) ++ req.Header.Set("Content-Type", "application/json") ++ ++ err := ctx.AuthZRequest(httptest.NewRecorder(), req) ++ if err == nil { ++ t.Fatal("expected AuthZRequest to reject body over max size") ++ } ++ if !strings.Contains(err.Error(), "request body too large for authorization plugin") { ++ t.Fatalf("unexpected error: %v", err) ++ } ++ ++ remaining, readErr := io.ReadAll(req.Body) ++ if readErr != nil { ++ t.Fatalf("failed to read request body after authz error: %v", readErr) ++ } ++ if string(remaining) != payload { ++ t.Fatalf("request body should still be preserved after over-limit check") + } + } + +-- +2.47.3 + diff -Nru docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-41567-decompress-before-entering-container-fs.patch docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-41567-decompress-before-entering-container-fs.patch --- docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-41567-decompress-before-entering-container-fs.patch 1970-01-01 00:00:00.000000000 +0000 +++ docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-41567-decompress-before-entering-container-fs.patch 2026-08-13 05:21:38.000000000 +0000 @@ -0,0 +1,162 @@ +From 83946f17c3196c55434aa0b8a8773d3477cbd3dc Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= +Date: Mon, 18 May 2026 19:38:46 +0200 +Subject: [PATCH] daemon: Decompress archives before entering container + filesystem +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Move decompression outside RunInFS to prevent executing +attacker-controlled binaries from within the container filesystem. + +When dockerd handles `PUT /containers/{id}/archive`, it switches root +into the container's filesystem before extracting the archive. +Previously, archive.Untar was called inside RunInFS, which meant +decompression binaries (xz, unpigz) were resolved via PATH inside the +container's filesystem. A malicious binary at /usr/bin/xz in the +container would be executed as host root. + +Fix by calling decompressing the archive before entering the container +filesystem, then using unpacking the uncompressed tar stream inside +RunInFS. +This ensures decompression binaries are always resolved from the host +filesystem. + +(cherry picked from commit 2022313ffe5a8c04890b5295bc52670ee6df8070) +Signed-off-by: Paweł Gronowski +--- + daemon/archive_unix.go | 13 +++- + integration/container/copy_linux_test.go | 89 ++++++++++++++++++++++++ + 2 files changed, 101 insertions(+), 1 deletion(-) + create mode 100644 integration/container/copy_linux_test.go + +diff --git a/engine/daemon/archive_unix.go b/engine/daemon/archive_unix.go +index 837e671c2c..38e58c8645 100644 +--- a/engine/daemon/archive_unix.go ++++ b/engine/daemon/archive_unix.go +@@ -101,6 +101,17 @@ func (daemon *Daemon) containerExtractToDir(container *container.Container, path + container.Lock() + defer container.Unlock() + ++ // Decompress the archive before entering the container filesystem. ++ // DecompressStream may invoke external binaries (xz, unpigz) resolved ++ // via PATH. Running it inside RunInFS would resolve those binaries ++ // from the container filesystem, allowing a malicious container to ++ // execute arbitrary code as host root. ++ decompressed, err := archive.DecompressStream(content) ++ if err != nil { ++ return err ++ } ++ defer decompressed.Close() ++ + cfs, err := daemon.openContainerFS(container) + if err != nil { + return err +@@ -150,7 +161,7 @@ func (daemon *Daemon) containerExtractToDir(container *container.Container, path + } + } + +- return archive.Untar(content, absPath, options) ++ return archive.UntarUncompressed(decompressed, absPath, options) + }) + if err != nil { + return err +diff --git a/engine/integration/container/copy_linux_test.go b/engine/integration/container/copy_linux_test.go +new file mode 100644 +index 0000000000..dcc1c05364 +--- /dev/null ++++ b/engine/integration/container/copy_linux_test.go +@@ -0,0 +1,89 @@ ++package container // import "github.com/docker/docker/integration/container" ++ ++import ( ++ "archive/tar" ++ "bytes" ++ "os/exec" ++ "testing" ++ ++ "github.com/docker/docker/api/types" ++ containertypes "github.com/docker/docker/api/types/container" ++ "github.com/docker/docker/integration/internal/build" ++ "github.com/docker/docker/integration/internal/container" ++ "github.com/docker/docker/testutil" ++ "github.com/docker/docker/testutil/daemon" ++ "github.com/docker/docker/testutil/fakecontext" ++ "gotest.tools/v3/assert" ++ is "gotest.tools/v3/assert/cmp" ++ "gotest.tools/v3/skip" ++) ++ ++// TestCopyToContainerDecompressOnHost verifies that archive decompression ++// happens on the host, not inside the container filesystem. ++// ++// A malicious container could place a trojan binary at /usr/bin/xz. If ++// decompression ran inside RunInFS, dockerd would execute that binary as ++// host root. By decompressing before entering the container FS, we ensure ++// only host binaries are used. ++func TestCopyToContainerDecompressOnHost(t *testing.T) { ++ skip.If(t, testEnv.IsRemoteDaemon, "cannot start daemon on remote test run") ++ skip.If(t, testEnv.DaemonInfo.OSType == "windows") ++ skip.If(t, testEnv.IsRootless) ++ ++ _, err := exec.LookPath("xz") ++ skip.If(t, err != nil, "xz not found in PATH") ++ ++ t.Parallel() ++ ++ ctx := testutil.StartSpan(baseContext, t) ++ ++ d := daemon.New(t) ++ d.StartWithBusybox(ctx, t, "--iptables=false") ++ defer d.Stop(t) ++ ++ apiClient := d.NewClientT(t) ++ ++ buildCtx := fakecontext.New(t, "", fakecontext.WithDockerfile(` ++ FROM busybox ++ RUN printf '#!/bin/sh\ntouch /compromised\n' > /bin/xz && chmod +x /bin/xz ++ `)) ++ defer buildCtx.Close() ++ ++ imageID := build.Do(ctx, t, apiClient, buildCtx) ++ ++ cID := container.Run(ctx, t, apiClient, container.WithImage(imageID), container.WithCmd("sleep", "infinity")) ++ defer apiClient.ContainerRemove(ctx, cID, containertypes.RemoveOptions{Force: true}) ++ ++ // Create an xz-compressed tar archive containing a single file. ++ var plainTar bytes.Buffer ++ tw := tar.NewWriter(&plainTar) ++ content := []byte("hello world") ++ _ = tw.WriteHeader(&tar.Header{ ++ Name: "hello.txt", ++ Mode: 0o644, ++ Size: int64(len(content)), ++ }) ++ _, _ = tw.Write(content) ++ _ = tw.Close() ++ ++ var xzBuf bytes.Buffer ++ xzCmd := exec.Command("xz", "-z") ++ xzCmd.Stdin = &plainTar ++ xzCmd.Stdout = &xzBuf ++ err = xzCmd.Run() ++ assert.NilError(t, err, "failed to compress tar with xz") ++ ++ err = apiClient.CopyToContainer(ctx, cID, "/tmp", &xzBuf, types.CopyToContainerOptions{}) ++ assert.NilError(t, err) ++ ++ // Verify the file was extracted correctly. ++ execRes, err := container.Exec(ctx, apiClient, cID, []string{"cat", "/tmp/hello.txt"}) ++ assert.NilError(t, err) ++ assert.Check(t, is.Equal(execRes.ExitCode, 0)) ++ assert.Check(t, is.Equal(execRes.Stdout(), "hello world")) ++ ++ // The malicious /usr/bin/xz inside the container must NOT have been executed. ++ execRes, err = container.Exec(ctx, apiClient, cID, []string{"test", "-f", "/compromised"}) ++ assert.NilError(t, err) ++ assert.Check(t, is.Equal(execRes.ExitCode, 1), "malicious xz binary inside container was executed") ++} +-- +2.47.3 + diff -Nru docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-41568-copy-symlink-escape.patch docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-41568-copy-symlink-escape.patch --- docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-41568-copy-symlink-escape.patch 1970-01-01 00:00:00.000000000 +0000 +++ docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-41568-copy-symlink-escape.patch 2026-08-13 05:23:06.000000000 +0000 @@ -0,0 +1,139 @@ +From: Paweł Gronowski +Date: Mon, 18 May 2026 19:35:27 +0200 +Subject: daemon/copy: Fix symlink escape in mount destination creation +Description: daemon/copy: Fix symlink escape in mount destination creation + Use os.Root to scope all filesystem operations in createIfNotExists to + the container root directory. + . + This prevents a TOCTOU attack where a container process swaps a path + component with a symlink between GetResourcePath resolution and + directory/file creation, which could allow writing to arbitrary host + paths outside the container. +Bug-Security: https://github.com/moby/moby/security/advisories/GHSA-vp62-88p7-qqf5 +Origin: backport, https://github.com/moby/moby/commit/742e28ed8d654372f0aa5b549da74c2f6e674194 +Applied-Upstream: v25.0.17, v29.5.1, 2.0.0-beta.14 +Forwarded: not-needed +Last-Update: 2026-08-13 +Comment: + Cherry-picked from the upstream 25.0 LTS branch (742e28ed8d), which is itself + a backport of master commit 64a22d80b93ddc1416b501b5145df02947312249. + . + The only deviation from the upstream commit is patch context: 26.1.5 carries an + extra "internal/compatcontext" import in daemon/containerfs_linux.go that is not + present on the 25.0 branch, so the import hunk was refreshed to apply at zero + fuzz (dpkg-source rejects any fuzz). + . + NOTE: this patch introduces calls to os.Root.MkdirAll, which requires Go 1.25. + Debian trixie only has Go 1.24, so it is followed immediately by + engine-go1.24-os-root-mkdirall.patch, which replaces those calls with an + equivalent helper built on the Go 1.24 os.Root API. + +--- a/engine/daemon/containerfs_linux.go ++++ b/engine/daemon/containerfs_linux.go +@@ -20,7 +20,6 @@ import ( + "github.com/docker/docker/internal/compatcontext" + "github.com/docker/docker/internal/mounttree" + "github.com/docker/docker/internal/unshare" +- "github.com/docker/docker/pkg/fileutils" + ) + + type future struct { +@@ -88,6 +87,13 @@ func (daemon *Daemon) openContainerFS(co + if err := mount.MakeRSlave("/"); err != nil { + return err + } ++ ++ root, err := os.OpenRoot(container.BaseFS) ++ if err != nil { ++ return fmt.Errorf("open container root: %w", err) ++ } ++ defer root.Close() ++ + for _, m := range mounts { + dest, err := container.GetResourcePath(m.Destination) + if err != nil { +@@ -99,7 +105,7 @@ func (daemon *Daemon) openContainerFS(co + if err != nil { + return err + } +- if err := fileutils.CreateIfNotExists(dest, stat.IsDir()); err != nil { ++ if err := createIfNotExists(root, strings.TrimPrefix(m.Destination, "/"), stat.IsDir()); err != nil { + return err + } + +@@ -247,6 +253,27 @@ func (vw *containerFSView) Stat(ctx cont + return stat, err + } + ++// createIfNotExists creates a file or a directory only if it does not already exist. ++// The path is scoped to root using [os.Root] to prevent symlink escape attacks. ++func createIfNotExists(root *os.Root, unsafePath string, isDir bool) error { ++ if isDir { ++ return root.MkdirAll(unsafePath, 0o755) ++ } ++ ++ parent := filepath.Dir(unsafePath) ++ if parent != "." && parent != "/" { ++ if err := root.MkdirAll(parent, 0o755); err != nil { ++ return err ++ } ++ } ++ ++ f, err := root.OpenFile(unsafePath, os.O_CREATE|os.O_WRONLY, 0o755) ++ if err != nil { ++ return err ++ } ++ return f.Close() ++} ++ + // makeMountRRO makes the mount recursively read-only. + func makeMountRRO(dest string) error { + attr := &unix.MountAttr{ +--- /dev/null ++++ b/engine/daemon/containerfs_linux_test.go +@@ -0,0 +1,45 @@ ++package daemon // import "github.com/docker/docker/daemon" ++ ++import ( ++ "os" ++ "path/filepath" ++ "testing" ++ ++ "gotest.tools/v3/assert" ++) ++ ++func TestCreateIfNotExists(t *testing.T) { ++ t.Run("directory", func(t *testing.T) { ++ dir := t.TempDir() ++ root, err := os.OpenRoot(dir) ++ assert.NilError(t, err) ++ defer root.Close() ++ ++ err = createIfNotExists(root, "tocreate", true) ++ assert.NilError(t, err) ++ ++ fileinfo, err := os.Stat(filepath.Join(dir, "tocreate")) ++ assert.NilError(t, err, "Did not create destination") ++ assert.Assert(t, fileinfo.IsDir(), "Should have been a dir, seems it's not") ++ ++ err = createIfNotExists(root, "tocreate", true) ++ assert.NilError(t, err, "Should not fail if already exists") ++ }) ++ t.Run("file", func(t *testing.T) { ++ dir := t.TempDir() ++ root, err := os.OpenRoot(dir) ++ assert.NilError(t, err) ++ defer root.Close() ++ ++ err = createIfNotExists(root, "file/to/create", false) ++ assert.NilError(t, err) ++ ++ fileinfo, err := os.Stat(filepath.Join(dir, "file/to/create")) ++ assert.NilError(t, err, "Did not create destination") ++ ++ assert.Assert(t, !fileinfo.IsDir(), "Should have been a file, but created a directory") ++ ++ err = createIfNotExists(root, "file/to/create", false) ++ assert.NilError(t, err, "Should not fail if already exists") ++ }) ++} diff -Nru docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-42306-pin-mount-target-fd.patch docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-42306-pin-mount-target-fd.patch --- docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-42306-pin-mount-target-fd.patch 1970-01-01 00:00:00.000000000 +0000 +++ docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-42306-pin-mount-target-fd.patch 2026-08-13 05:24:15.000000000 +0000 @@ -0,0 +1,150 @@ +From 63772fe8630ee74f7c45d20637f3161cc750f4da Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= +Date: Thu, 26 Mar 2026 18:49:45 +0100 +Subject: [PATCH] Fix bind mount target redirection via symlink swap during + docker cp +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Pin mount targets via /proc/self/fd file descriptors to prevent TOCTOU +attacks. + +Previously, a container process could swap a path component with a +symlink between GetResourcePath resolution and directory/file creation +or mount, allowing writes to arbitrary host paths outside the container. + +Open the resolved destination through os.Root to get a pinned fd, then +mount onto /proc/self/fd/ instead of re-resolving the +container-relative path. This closes the TOCTOU window between +createIfNotExists and mount. + +Because the kernel rejects remount and propagation-change syscalls on +/proc/self/fd paths, the initial bind mount uses the fd path for safety, +then Readlink resolves the real path for the subsequent read-only +remount and rprivate propagation change. + +Signed-off-by: Paweł Gronowski +(cherry picked from commit 43fa458a9c40873867e75221454de10709b04236) +Signed-off-by: Cory Snider +--- + daemon/containerfs_linux.go | 56 +++++++++++++++++++++++++++++-------- + 1 file changed, 45 insertions(+), 11 deletions(-) + +diff --git a/engine/daemon/containerfs_linux.go b/engine/daemon/containerfs_linux.go +index 5f3585e4bf..6ec223af5c 100644 +--- a/engine/daemon/containerfs_linux.go ++++ b/engine/daemon/containerfs_linux.go +@@ -7,7 +7,7 @@ + "os" + "path/filepath" + "runtime" +- "strings" ++ "strconv" + + "github.com/containerd/log" + "github.com/hashicorp/go-multierror" +@@ -89,10 +89,14 @@ func() error { + } + defer root.Close() + ++ // TODO(vvoland): Refactor this after security release. + for _, m := range mounts { +- dest, err := container.GetResourcePath(m.Destination) ++ // Destination is an absolute path within container ++ // filesystem. For the os.Root to work, we need to convert it ++ // to a path relative to root fs / ++ relDest, err := filepath.Rel("/", m.Destination) + if err != nil { +- return err ++ return fmt.Errorf("make destination relative: %w", err) + } + + var stat os.FileInfo +@@ -100,7 +104,7 @@ func() error { + if err != nil { + return err + } +- if err := createIfNotExists(root, strings.TrimPrefix(m.Destination, "/"), stat.IsDir()); err != nil { ++ if err := createIfNotExists(root, relDest, stat.IsDir()); err != nil { + return err + } + +@@ -108,9 +112,7 @@ func() error { + if m.NonRecursive { + bindMode = "bind" + } +- writeMode := "ro" + if m.Writable { +- writeMode = "rw" + if m.ReadOnlyNonRecursive { + return errors.New("options conflict: Writable && ReadOnlyNonRecursive") + } +@@ -122,6 +124,37 @@ func() error { + return errors.New("options conflict: ReadOnlyNonRecursive && ReadOnlyForceRecursive") + } + ++ // Open the mount target through os.Root so we have a ++ // file descriptor pinning the resolved inode. Using ++ // /proc/self/fd/ as the mount target prevents any ++ // subsequent symlink swap from redirecting the mount. ++ targetFile, err := root.Open(relDest) ++ if err != nil { ++ return fmt.Errorf("open mount target %q: %w", m.Destination, err) ++ } ++ targetPath := "/proc/self/fd/" + strconv.FormatUint(uint64(targetFile.Fd()), 10) ++ ++ // The kernel rejects remount and propagation-change syscalls ++ // when the target is a /proc/self/fd path. Only the initial ++ // bind mount works on such paths, so we perform that via the ++ // fd path for TOCTOU safety and then resolve the real path for ++ // the read-only remount and propagation change. ++ if err := mount.Mount(m.Source, targetPath, "", bindMode); err != nil { ++ targetFile.Close() ++ return err ++ } ++ realPath, err := os.Readlink(targetPath) ++ if err != nil { ++ targetFile.Close() ++ return fmt.Errorf("readlink %s: %w", targetPath, err) ++ } ++ if !m.Writable { ++ if err := mount.Mount("", realPath, "", "ro,remount,bind"); err != nil { ++ targetFile.Close() ++ return err ++ } ++ } ++ + // openContainerFS() is called for temporary mounts + // outside the container. Soon these will be unmounted + // with lazy unmount option and given we have mounted +@@ -132,20 +165,21 @@ func() error { + // all these mounts rprivate. Do not use propagation + // property of volume as that should apply only when + // mounting happens inside the container. +- opts := strings.Join([]string{bindMode, writeMode, "rprivate"}, ",") +- if err := mount.Mount(m.Source, dest, "", opts); err != nil { ++ if err := mount.MakeRPrivate(realPath); err != nil { ++ targetFile.Close() + return err + } + + if !m.Writable && !m.ReadOnlyNonRecursive { +- if err := makeMountRRO(dest); err != nil { ++ if err := makeMountRRO(realPath); err != nil { ++ targetFile.Close() + if m.ReadOnlyForceRecursive { + return err +- } else { +- log.G(context.TODO()).WithError(err).Debugf("Failed to make %q recursively read-only", dest) + } ++ log.G(context.TODO()).WithError(err).Debugf("Failed to make %q recursively read-only", m.Destination) + } + } ++ targetFile.Close() + } + + return mounttree.SwitchRoot(container.BaseFS) +-- +2.47.3 + diff -Nru docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-42306-resolve-in-container-symlinks.patch docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-42306-resolve-in-container-symlinks.patch --- docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-42306-resolve-in-container-symlinks.patch 1970-01-01 00:00:00.000000000 +0000 +++ docker.io-26.1.5+dfsg1/debian/patches/engine-CVE-2026-42306-resolve-in-container-symlinks.patch 2026-08-13 05:24:29.000000000 +0000 @@ -0,0 +1,126 @@ +From f82f5a92d2ca3a2337b167bdc0a91d99a89756ef Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= +Date: Tue, 19 May 2026 16:03:30 +0200 +Subject: [PATCH] daemon: resolve in-container symlinks before os.Root mount + ops +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +The security fix in GHSA-vp62-88p7-qqf5 switched openContainerFS to +os.Root for mount-destination operations, but stopped walking the +destination through in-container symlinks. + +os.Root refuses to follow absolute symlinks, so any container whose +image had an absolute symlink along the mount target's path (e.g. the +common /var/run -> /run in ubuntu/alpine/busybox) broke `docker cp`. + +Walk m.Destination through ctr.GetResourcePath first which follows +symlinks to get a path relative to BaseFS, then keep using os.Root for +the actual MkdirAll/OpenFile/Open calls. + +Signed-off-by: Paweł Gronowski +(cherry picked from commit fb3702d033601bb0e767f2ca398e3909a15be29c) +Signed-off-by: Cory Snider +--- + daemon/containerfs_linux.go | 14 +++++-- + integration/container/copy_linux_test.go | 49 ++++++++++++++++++++++++ + 2 files changed, 59 insertions(+), 4 deletions(-) + +diff --git a/engine/daemon/containerfs_linux.go b/engine/daemon/containerfs_linux.go +index 6ec223af5c..3d97cb8ba9 100644 +--- a/engine/daemon/containerfs_linux.go ++++ b/engine/daemon/containerfs_linux.go +@@ -91,10 +91,16 @@ func() error { + + // TODO(vvoland): Refactor this after security release. + for _, m := range mounts { +- // Destination is an absolute path within container +- // filesystem. For the os.Root to work, we need to convert it +- // to a path relative to root fs / +- relDest, err := filepath.Rel("/", m.Destination) ++ // Walk m.Destination through the container's symlinks before ++ // passing it to os.Root, which refuses absolute symlinks ++ // (e.g. the common /var/run -> /run). The resolution itself ++ // is lexical; subsequent os.Root operations still enforce ++ // the GHSA-vp62-88p7-qqf5 / GHSA-rg2x-37c3-w2rh protections. ++ resolved, err := container.GetResourcePath(m.Destination) ++ if err != nil { ++ return fmt.Errorf("resolve mount destination %q: %w", m.Destination, err) ++ } ++ relDest, err := filepath.Rel(container.BaseFS, resolved) + if err != nil { + return fmt.Errorf("make destination relative: %w", err) + } +diff --git a/engine/integration/container/copy_linux_test.go b/engine/integration/container/copy_linux_test.go +index dcc1c05364..1c0c77d96b 100644 +--- a/engine/integration/container/copy_linux_test.go ++++ b/engine/integration/container/copy_linux_test.go +@@ -3,11 +3,14 @@ + import ( + "archive/tar" + "bytes" ++ "os" + "os/exec" ++ "path/filepath" + "testing" + + "github.com/docker/docker/api/types" + containertypes "github.com/docker/docker/api/types/container" ++ mounttypes "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/integration/internal/build" + "github.com/docker/docker/integration/internal/container" + "github.com/docker/docker/testutil" +@@ -87,3 +90,49 @@ func TestCopyToContainerDecompressOnHost(t *testing.T) { + assert.NilError(t, err) + assert.Check(t, is.Equal(execRes.ExitCode, 1), "malicious xz binary inside container was executed") + } ++ ++// TestCopyWithAbsoluteSymlinkedMountTarget was introduced as a regression test ++// for https://github.com/moby/moby/issues/52653. ++// ++// The security fix in GHSA-vp62-88p7-qqf5 switched openContainerFS to use ++// os.Root for the mount-destination operations. ++// os.Root refuses to follow absolute symlinks, but distro images commonly ship ++// /var/run as an absolute symlink to /run. ++// As a result, any container with a bind mount whose target traversed such a ++// symlink (e.g. -v /host/sock:/var/run/docker.sock) made `docker cp` fail. ++func TestCopyWithAbsoluteSymlinkedMountTarget(t *testing.T) { ++ skip.If(t, testEnv.DaemonInfo.OSType != "linux") ++ ctx := setupTest(t) ++ apiClient := testEnv.APIClient() ++ ++ // Build an image with an absolute in-container symlink along the mount ++ // target path. ++ // Stock distro images expose this shape via /var/run -> /run, but we set ++ // up our own /sockets -> /root pair so the test does not depend on any ++ // particular base image's layout. ++ buildCtx := fakecontext.New(t, "", ++ fakecontext.WithDockerfile(`FROM busybox ++RUN touch /root/nil && ln -s /root /sockets ++`), ++ ) ++ defer buildCtx.Close() ++ imgID := build.Do(ctx, t, apiClient, buildCtx) ++ ++ // Use testutil.TempDir so the rootless daemon can access the bind-mount ++ // source: t.TempDir() creates a 0700 parent that the fake-root user ++ // cannot stat. ++ srcDir := testutil.TempDir(t) ++ assert.NilError(t, os.WriteFile(filepath.Join(srcDir, "sock"), nil, 0o644)) ++ ++ cid := container.Create(ctx, t, apiClient, ++ container.WithImage(imgID), ++ container.WithMount(mounttypes.Mount{ ++ Type: mounttypes.TypeBind, ++ Source: filepath.Join(srcDir, "sock"), ++ Target: "/sockets/docker.sock", ++ }), ++ ) ++ ++ err := apiClient.CopyToContainer(ctx, cid, "/sockets/", bytes.NewReader(nil), types.CopyToContainerOptions{}) ++ assert.NilError(t, err) ++} +-- +2.47.3 + diff -Nru docker.io-26.1.5+dfsg1/debian/patches/engine-go1.24-os-root-mkdirall.patch docker.io-26.1.5+dfsg1/debian/patches/engine-go1.24-os-root-mkdirall.patch --- docker.io-26.1.5+dfsg1/debian/patches/engine-go1.24-os-root-mkdirall.patch 1970-01-01 00:00:00.000000000 +0000 +++ docker.io-26.1.5+dfsg1/debian/patches/engine-go1.24-os-root-mkdirall.patch 2026-08-13 05:23:55.000000000 +0000 @@ -0,0 +1,80 @@ +Description: build the os.Root security fixes with Go 1.24 + The upstream fix for GHSA-vp62-88p7-qqf5 (CVE-2026-41568) rewrites + createIfNotExists() on top of the os.Root API and calls os.Root.MkdirAll(). + . + os.Root itself landed in Go 1.24, but the MkdirAll method was only added in + Go 1.25. Debian trixie ships Go 1.24 only (golang-any 2:1.24~2), so the + upstream code as written does not compile there. + . + Replace the two os.Root.MkdirAll() calls with a local mkdirAllInRoot() helper + that walks the path one component at a time using os.Root.Mkdir(), which is + available in Go 1.24. The security property is unchanged: every component is + still created through the *os.Root handle, so the kernel-side scoping that + blocks symlink escapes out of the container root still applies. Error + semantics match os.MkdirAll (success if the path already exists as a + directory, ENOTDIR if it exists as a non-directory). + . + This patch can be dropped once docker.io is built against Go >= 1.25. +Author: Aron Xu +Forwarded: not-needed +Last-Update: 2026-08-13 + +--- a/engine/daemon/containerfs_linux.go ++++ b/engine/daemon/containerfs_linux.go +@@ -257,12 +257,12 @@ func (vw *containerFSView) Stat(ctx cont + // The path is scoped to root using [os.Root] to prevent symlink escape attacks. + func createIfNotExists(root *os.Root, unsafePath string, isDir bool) error { + if isDir { +- return root.MkdirAll(unsafePath, 0o755) ++ return mkdirAllInRoot(root, unsafePath, 0o755) + } + + parent := filepath.Dir(unsafePath) + if parent != "." && parent != "/" { +- if err := root.MkdirAll(parent, 0o755); err != nil { ++ if err := mkdirAllInRoot(root, parent, 0o755); err != nil { + return err + } + } +@@ -274,6 +274,41 @@ func createIfNotExists(root *os.Root, un + return f.Close() + } + ++// mkdirAllInRoot creates unsafePath and any missing parents, scoped to root. ++// ++// It is a stand-in for [os.Root.MkdirAll], which is only available from Go 1.25 ++// onwards; Debian trixie ships Go 1.24. Like [os.MkdirAll] it succeeds if the ++// path already exists as a directory, and like the rest of the [os.Root] API it ++// refuses to traverse symlinks that escape root. ++func mkdirAllInRoot(root *os.Root, unsafePath string, perm os.FileMode) error { ++ unsafePath = filepath.Clean(unsafePath) ++ if unsafePath == "." || unsafePath == string(filepath.Separator) { ++ return nil ++ } ++ ++ if parent := filepath.Dir(unsafePath); parent != unsafePath { ++ if err := mkdirAllInRoot(root, parent, perm); err != nil { ++ return err ++ } ++ } ++ ++ if err := root.Mkdir(unsafePath, perm); err != nil { ++ if !errors.Is(err, os.ErrExist) { ++ return err ++ } ++ // Already exists: it must be a directory (or a symlink to one ++ // inside root), matching the semantics of [os.MkdirAll]. ++ fi, statErr := root.Stat(unsafePath) ++ if statErr != nil { ++ return statErr ++ } ++ if !fi.IsDir() { ++ return &os.PathError{Op: "mkdir", Path: unsafePath, Err: unix.ENOTDIR} ++ } ++ } ++ return nil ++} ++ + // makeMountRRO makes the mount recursively read-only. + func makeMountRRO(dest string) error { + attr := &unix.MountAttr{ diff -Nru docker.io-26.1.5+dfsg1/debian/patches/series docker.io-26.1.5+dfsg1/debian/patches/series --- docker.io-26.1.5+dfsg1/debian/patches/series 2025-02-20 22:27:00.000000000 +0000 +++ docker.io-26.1.5+dfsg1/debian/patches/series 2026-08-13 05:29:38.000000000 +0000 @@ -33,3 +33,13 @@ grpc-middleware-v1.patch buildkit-remove-hcsshim.patch buildkit-remove-jaeger.patch +engine-CVE-2026-41567-decompress-before-entering-container-fs.patch +engine-CVE-2026-34040-authz-reject-oversized-body.patch +engine-CVE-2026-34040-authz-body-limit-4mib.patch +engine-CVE-2026-33997-plugin-privilege-off-by-one.patch +engine-CVE-2026-41568-copy-symlink-escape.patch +engine-go1.24-os-root-mkdirall.patch +engine-CVE-2026-42306-pin-mount-target-fd.patch +engine-CVE-2026-42306-resolve-in-container-symlinks.patch +buildkit-CVE-2026-33747-validate-container-id.patch +buildkit-CVE-2026-33748-git-subdir-symlink-escape.patch