From 2ef1cd7f7e2416b002bcc029b638a5032294492b Mon Sep 17 00:00:00 2001 From: Paul Holzinger Date: Fri, 29 Aug 2025 15:39:38 +0200 Subject: [PATCH 1/2] [v5.4-rhel] kube play: don't follow volume symlinks onto the host For ConfigMap and Secret kube play volumes podman populates the data from the yaml. However the volume content is not controlled by us and we can be tricked following a symlink to a file on the host instead. Fixes: CVE-2025-9566 Fixes: https://issues.redhat.com/browse/RHEL-113141, https://issues.redhat.com/browse/RHEL-113152, https://issues.redhat.com/browse/OCPBUGS-61268, https://issues.redhat.com/browse/OCPBUGS-61270 Signed-off-by: Paul Holzinger Signed-off-by: tomsweeneyredhat --- pkg/domain/infra/abi/play.go | 5 ++- pkg/domain/infra/abi/play_linux.go | 18 +++++++++++ pkg/domain/infra/abi/play_unsupported.go | 13 ++++++++ pkg/domain/infra/abi/play_utils.go | 39 +++++++++++++++++++++++- 4 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 pkg/domain/infra/abi/play_linux.go create mode 100644 pkg/domain/infra/abi/play_unsupported.go diff --git a/pkg/domain/infra/abi/play.go b/pkg/domain/infra/abi/play.go index 2d9b780716..63a5f64f19 100644 --- a/pkg/domain/infra/abi/play.go +++ b/pkg/domain/infra/abi/play.go @@ -788,8 +788,7 @@ func (ic *ContainerEngine) playKubePod(ctx context.Context, podName string, podY defaultMode := v.DefaultMode // Create files and add data to the volume mountpoint based on the Items in the volume for k, v := range v.Items { - dataPath := filepath.Join(mountPoint, k) - f, err := os.Create(dataPath) + f, err := openPathSafely(mountPoint, k) if err != nil { return nil, nil, fmt.Errorf("cannot create file %q at volume mountpoint %q: %w", k, mountPoint, err) } @@ -799,7 +798,7 @@ func (ic *ContainerEngine) playKubePod(ctx context.Context, podName string, podY return nil, nil, err } // Set file permissions - if err := os.Chmod(f.Name(), os.FileMode(defaultMode)); err != nil { + if err := f.Chmod(os.FileMode(defaultMode)); err != nil { return nil, nil, err } } diff --git a/pkg/domain/infra/abi/play_linux.go b/pkg/domain/infra/abi/play_linux.go new file mode 100644 index 0000000000..a0f9811516 --- /dev/null +++ b/pkg/domain/infra/abi/play_linux.go @@ -0,0 +1,18 @@ +//go:build !remote + +package abi + +import ( + "os" + + securejoin "github.com/cyphar/filepath-securejoin" +) + +// openSymlinkPath opens the path under root using securejoin.OpenatInRoot(). +func openSymlinkPath(root *os.File, unsafePath string, flags int) (*os.File, error) { + file, err := securejoin.OpenatInRoot(root, unsafePath) + if err != nil { + return nil, err + } + return securejoin.Reopen(file, flags) +} diff --git a/pkg/domain/infra/abi/play_unsupported.go b/pkg/domain/infra/abi/play_unsupported.go new file mode 100644 index 0000000000..3ecbae7cc1 --- /dev/null +++ b/pkg/domain/infra/abi/play_unsupported.go @@ -0,0 +1,13 @@ +//go:build !linux && !remote + +package abi + +import ( + "errors" + "os" +) + +// openSymlinkPath is not supported on this platform. +func openSymlinkPath(root *os.File, unsafePath string, flags int) (*os.File, error) { + return nil, errors.New("cannot safely open symlink on this platform") +} diff --git a/pkg/domain/infra/abi/play_utils.go b/pkg/domain/infra/abi/play_utils.go index 7285d9c9b9..217b656997 100644 --- a/pkg/domain/infra/abi/play_utils.go +++ b/pkg/domain/infra/abi/play_utils.go @@ -2,7 +2,14 @@ package abi -import "github.com/containers/podman/v5/libpod/define" +import ( + "fmt" + "os" + "strings" + + "github.com/containers/podman/v5/libpod/define" + "golang.org/x/sys/unix" +) // getSdNotifyMode returns the `sdNotifyAnnotation/$name` for the specified // name. If name is empty, it'll only look for `sdNotifyAnnotation`. @@ -16,3 +23,33 @@ func getSdNotifyMode(annotations map[string]string, name string) (string, error) } return mode, define.ValidateSdNotifyMode(mode) } + +// openPathSafely opens the given name under the trusted root path, the unsafeName +// must be a single path component and not contain "/". +// The resulting path will be opened or created if it does not exists. +// Following of symlink is done within staying under root, escapes outsides +// of root are not allowed and prevent. +// +// This custom function is needed because securejoin.SecureJoin() is not race safe +// and the volume might be mounted in another container that could swap in a symlink +// after the function ahs run. securejoin.OpenInRoot() doesn't work either because +// it cannot create files and doesn't work on freebsd. +func openPathSafely(root, unsafeName string) (*os.File, error) { + if strings.Contains(unsafeName, "/") { + return nil, fmt.Errorf("name %q must not contain path separator", unsafeName) + } + fdDir, err := os.OpenFile(root, unix.O_RDONLY, 0) + if err != nil { + return nil, err + } + defer fdDir.Close() + flags := unix.O_CREAT | unix.O_WRONLY | unix.O_TRUNC | unix.O_CLOEXEC + fd, err := unix.Openat(int(fdDir.Fd()), unsafeName, flags|unix.O_NOFOLLOW, 0o644) + if err == nil { + return os.NewFile(uintptr(fd), unsafeName), nil + } + if err == unix.ELOOP { + return openSymlinkPath(fdDir, unsafeName, flags) + } + return nil, &os.PathError{Op: "openat", Path: unsafeName, Err: err} +} From 89b55ab34d10336da465cbf8b61429a3e3c6bf66 Mon Sep 17 00:00:00 2001 From: Paul Holzinger Date: Fri, 5 Sep 2025 18:17:17 +0200 Subject: [PATCH 2/2] [v5.4-rhel] test/e2e: add CVE-2025-9566 regression test Ensure we do not regress again. Signed-off-by: Paul Holzinger Signed-off-by: tomsweeneyredhat --- test/e2e/play_kube_test.go | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/test/e2e/play_kube_test.go b/test/e2e/play_kube_test.go index a739618249..8fb341ec28 100644 --- a/test/e2e/play_kube_test.go +++ b/test/e2e/play_kube_test.go @@ -2157,7 +2157,7 @@ func getPersistentVolumeClaimVolume(vName string) *Volume { // getConfigMap returns a new ConfigMap Volume given the name and items // of the ConfigMap. -func getConfigMapVolume(vName string, items []map[string]string, optional bool, defaultMode *int32) *Volume { //nolint:unparam +func getConfigMapVolume(vName string, items []map[string]string, optional bool, defaultMode *int32) *Volume { vol := &Volume{ VolumeType: "ConfigMap", Name: defaultVolName, @@ -6182,4 +6182,32 @@ spec: Expect(execArr[len(execArr)-1]).To(Not(ContainSubstring(arr[len(arr)-1]))) }) + It("CVE-2025-9566 regression test - ConfigMap mount", func() { + testfile := filepath.Join(podmanTest.TempDir, "testfile") + volumeName := "cm-vol" + cm := getConfigMap(withConfigMapName(volumeName), withConfigMapData("foo", "content1")) + cmYaml, err := getKubeYaml("configmap", cm) + Expect(err).ToNot(HaveOccurred()) + + ctrName := "ctr1" + podName := "pod1" + // create a symlink at the volume mount location so we can make sure we don't resolve that to the host location. + ctr := getCtr(withName(ctrName), withVolumeMount("/test", "", false), withImage(CITEST_IMAGE), withCmd([]string{"sh", "-c", "ln -sf " + testfile + " /test/foo"})) + pod := getPod(withPodName(podName), withVolume(getConfigMapVolume(volumeName, nil, false, nil)), withCtr(ctr)) + podYaml, err := getKubeYaml("pod", pod) + Expect(err).ToNot(HaveOccurred()) + yamls := []string{cmYaml, podYaml} + err = generateMultiDocKubeYaml(yamls, kubeYaml) + Expect(err).ToNot(HaveOccurred()) + + podmanTest.PodmanExitCleanly("kube", "play", kubeYaml) + // wait for the container to finish to ensure the symlink was created + podmanTest.PodmanExitCleanly("wait", podName+"-"+ctrName) + podmanTest.PodmanExitCleanly("kube", "down", kubeYaml) + kube := podmanTest.Podman([]string{"kube", "play", kubeYaml}) + kube.WaitWithDefaultTimeout() + Expect(kube).To(ExitWithError(125, `cannot create file "foo" at volume mountpoint`)) + + Expect(testfile).ToNot(BeAnExistingFile(), "file should never be created on the host") + }) })