Skip to content

Commit 52c3a0e

Browse files
authored
Merge pull request #4985 from cyphar/hallucinated-paths
pathrs: add "hallucination" helpers for SecureJoin magic
2 parents 475473d + 15d7c21 commit 52c3a0e

File tree

14 files changed

+335
-205
lines changed

14 files changed

+335
-205
lines changed

internal/pathrs/mkdirall.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
/*
3+
* Copyright (C) 2024-2025 Aleksa Sarai <[email protected]>
4+
* Copyright (C) 2024-2025 SUSE LLC
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
package pathrs
20+
21+
import (
22+
"fmt"
23+
"os"
24+
"path/filepath"
25+
)
26+
27+
// MkdirAllParentInRoot is like [MkdirAllInRoot] except that it only creates
28+
// the parent directory of the target path, returning the trailing component so
29+
// the caller has more flexibility around constructing the final inode.
30+
//
31+
// Callers need to be very careful operating on the trailing path, as trivial
32+
// mistakes like following symlinks can cause security bugs. Most people
33+
// should probably just use [MkdirAllInRoot] or [CreateInRoot].
34+
func MkdirAllParentInRoot(root, unsafePath string, mode os.FileMode) (*os.File, string, error) {
35+
// MkdirAllInRoot also does hallucinateUnsafePath, but we need to do it
36+
// here first because when we split unsafePath into (dir, file) components
37+
// we want to be doing so with the hallucinated path (so that trailing
38+
// dangling symlinks are treated correctly).
39+
unsafePath, err := hallucinateUnsafePath(root, unsafePath)
40+
if err != nil {
41+
return nil, "", fmt.Errorf("failed to construct hallucinated target path: %w", err)
42+
}
43+
44+
dirPath, filename := filepath.Split(unsafePath)
45+
if filepath.Join("/", filename) == "/" {
46+
return nil, "", fmt.Errorf("create parent dir in root subpath %q has bad trailing component %q", unsafePath, filename)
47+
}
48+
49+
dirFd, err := MkdirAllInRoot(root, dirPath, mode)
50+
return dirFd, filename, err
51+
}

internal/pathrs/mkdirall_pathrslite.go

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,13 @@ package pathrs
2121
import (
2222
"fmt"
2323
"os"
24-
"path/filepath"
2524

2625
"github.com/cyphar/filepath-securejoin/pathrs-lite"
2726
"github.com/sirupsen/logrus"
2827
"golang.org/x/sys/unix"
2928
)
3029

31-
// MkdirAllInRootOpen attempts to make
30+
// MkdirAllInRoot attempts to make
3231
//
3332
// path, _ := securejoin.SecureJoin(root, unsafePath)
3433
// os.MkdirAll(path, mode)
@@ -49,19 +48,10 @@ import (
4948
// handling if unsafePath has already been scoped within the rootfs (this is
5049
// needed for a lot of runc callers and fixing this would require reworking a
5150
// lot of path logic).
52-
func MkdirAllInRootOpen(root, unsafePath string, mode os.FileMode) (*os.File, error) {
53-
// If the path is already "within" the root, get the path relative to the
54-
// root and use that as the unsafe path. This is necessary because a lot of
55-
// MkdirAllInRootOpen callers have already done SecureJoin, and refactoring
56-
// all of them to stop using these SecureJoin'd paths would require a fair
57-
// amount of work.
58-
// TODO(cyphar): Do the refactor to libpathrs once it's ready.
59-
if IsLexicallyInRoot(root, unsafePath) {
60-
subPath, err := filepath.Rel(root, unsafePath)
61-
if err != nil {
62-
return nil, err
63-
}
64-
unsafePath = subPath
51+
func MkdirAllInRoot(root, unsafePath string, mode os.FileMode) (*os.File, error) {
52+
unsafePath, err := hallucinateUnsafePath(root, unsafePath)
53+
if err != nil {
54+
return nil, fmt.Errorf("failed to construct hallucinated target path: %w", err)
6555
}
6656

6757
// Check for any silly mode bits.
@@ -87,13 +77,3 @@ func MkdirAllInRootOpen(root, unsafePath string, mode os.FileMode) (*os.File, er
8777
return pathrs.MkdirAllHandle(rootDir, unsafePath, mode)
8878
})
8979
}
90-
91-
// MkdirAllInRoot is a wrapper around MkdirAllInRootOpen which closes the
92-
// returned handle, for callers that don't need to use it.
93-
func MkdirAllInRoot(root, unsafePath string, mode os.FileMode) error {
94-
f, err := MkdirAllInRootOpen(root, unsafePath, mode)
95-
if err == nil {
96-
_ = f.Close()
97-
}
98-
return err
99-
}

internal/pathrs/path.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@
1919
package pathrs
2020

2121
import (
22+
"os"
23+
"path/filepath"
2224
"strings"
25+
26+
securejoin "github.com/cyphar/filepath-securejoin"
2327
)
2428

2529
// IsLexicallyInRoot is shorthand for strings.HasPrefix(path+"/", root+"/"),
@@ -32,3 +36,81 @@ func IsLexicallyInRoot(root, path string) bool {
3236
path = strings.TrimRight(path, "/")
3337
return strings.HasPrefix(path+"/", root+"/")
3438
}
39+
40+
// LexicallyCleanPath makes a path safe for use with filepath.Join. This is
41+
// done by not only cleaning the path, but also (if the path is relative)
42+
// adding a leading '/' and cleaning it (then removing the leading '/'). This
43+
// ensures that a path resulting from prepending another path will always
44+
// resolve to lexically be a subdirectory of the prefixed path. This is all
45+
// done lexically, so paths that include symlinks won't be safe as a result of
46+
// using CleanPath.
47+
func LexicallyCleanPath(path string) string {
48+
// Deal with empty strings nicely.
49+
if path == "" {
50+
return ""
51+
}
52+
53+
// Ensure that all paths are cleaned (especially problematic ones like
54+
// "/../../../../../" which can cause lots of issues).
55+
56+
if filepath.IsAbs(path) {
57+
return filepath.Clean(path)
58+
}
59+
60+
// If the path isn't absolute, we need to do more processing to fix paths
61+
// such as "../../../../<etc>/some/path". We also shouldn't convert absolute
62+
// paths to relative ones.
63+
path = filepath.Clean(string(os.PathSeparator) + path)
64+
// This can't fail, as (by definition) all paths are relative to root.
65+
path, _ = filepath.Rel(string(os.PathSeparator), path)
66+
67+
return path
68+
}
69+
70+
// LexicallyStripRoot returns the passed path, stripping the root path if it
71+
// was (lexicially) inside it. Note that both passed paths will always be
72+
// treated as absolute, and the returned path will also always be absolute. In
73+
// addition, the paths are cleaned before stripping the root.
74+
func LexicallyStripRoot(root, path string) string {
75+
// Make the paths clean and absolute.
76+
root, path = LexicallyCleanPath("/"+root), LexicallyCleanPath("/"+path)
77+
switch {
78+
case path == root:
79+
path = "/"
80+
case root == "/":
81+
// do nothing
82+
default:
83+
path = strings.TrimPrefix(path, root+"/")
84+
}
85+
return LexicallyCleanPath("/" + path)
86+
}
87+
88+
// hallucinateUnsafePath creates a new unsafePath which has all symlinks
89+
// (including dangling symlinks) fully resolved and any non-existent components
90+
// treated as though they are real. This is effectively just a wrapper around
91+
// [securejoin.SecureJoin] that strips the root. This path *IS NOT* safe to use
92+
// as-is, you *MUST* operate on the returned path with pathrs-lite.
93+
//
94+
// The reason for this methods is that in previous runc versions, we would
95+
// tolerate nonsense paths with dangling symlinks as path components.
96+
// pathrs-lite does not support this, so instead we have to emulate this
97+
// behaviour by doing SecureJoin *purely to get a semi-reasonable path to use*
98+
// and then we use pathrs-lite to operate on the path safely.
99+
//
100+
// It would be quite difficult to emulate this in a race-free way in
101+
// pathrs-lite, so instead we use [securejoin.SecureJoin] to simply produce a
102+
// new candidate path for operations like [MkdirAllInRoot] so they can then
103+
// operate on the new unsafePath as if it was what the user requested.
104+
//
105+
// If unsafePath is already lexically inside root, it is stripped before
106+
// re-resolving it (this is done to ensure compatibility with legacy callers
107+
// within runc that call SecureJoin before calling into pathrs).
108+
func hallucinateUnsafePath(root, unsafePath string) (string, error) {
109+
unsafePath = LexicallyStripRoot(root, unsafePath)
110+
weirdPath, err := securejoin.SecureJoin(root, unsafePath)
111+
if err != nil {
112+
return "", err
113+
}
114+
unsafePath = LexicallyStripRoot(root, weirdPath)
115+
return unsafePath, nil
116+
}

internal/pathrs/path_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,70 @@ func TestIsLexicallyInRoot(t *testing.T) {
5151
})
5252
}
5353
}
54+
55+
func TestLexicallyCleanPath(t *testing.T) {
56+
path := LexicallyCleanPath("")
57+
if path != "" {
58+
t.Errorf("expected to receive empty string and received %s", path)
59+
}
60+
61+
path = LexicallyCleanPath("rootfs")
62+
if path != "rootfs" {
63+
t.Errorf("expected to receive 'rootfs' and received %s", path)
64+
}
65+
66+
path = LexicallyCleanPath("../../../var")
67+
if path != "var" {
68+
t.Errorf("expected to receive 'var' and received %s", path)
69+
}
70+
71+
path = LexicallyCleanPath("/../../../var")
72+
if path != "/var" {
73+
t.Errorf("expected to receive '/var' and received %s", path)
74+
}
75+
76+
path = LexicallyCleanPath("/foo/bar/")
77+
if path != "/foo/bar" {
78+
t.Errorf("expected to receive '/foo/bar' and received %s", path)
79+
}
80+
81+
path = LexicallyCleanPath("/foo/bar/../")
82+
if path != "/foo" {
83+
t.Errorf("expected to receive '/foo' and received %s", path)
84+
}
85+
}
86+
87+
func TestLexicallyStripRoot(t *testing.T) {
88+
for _, test := range []struct {
89+
root, path, out string
90+
}{
91+
// Works with multiple components.
92+
{"/a/b", "/a/b/c", "/c"},
93+
{"/hello/world", "/hello/world/the/quick-brown/fox", "/the/quick-brown/fox"},
94+
// '/' must be a no-op.
95+
{"/", "/a/b/c", "/a/b/c"},
96+
// Must be the correct order.
97+
{"/a/b", "/a/c/b", "/a/c/b"},
98+
// Must be at start.
99+
{"/abc/def", "/foo/abc/def/bar", "/foo/abc/def/bar"},
100+
// Must be a lexical parent.
101+
{"/foo/bar", "/foo/barSAMECOMPONENT", "/foo/barSAMECOMPONENT"},
102+
// Must only strip the root once.
103+
{"/foo/bar", "/foo/bar/foo/bar/baz", "/foo/bar/baz"},
104+
// Deal with .. in a fairly sane way.
105+
{"/foo/bar", "/foo/bar/../baz", "/foo/baz"},
106+
{"/foo/bar", "../../../../../../foo/bar/baz", "/baz"},
107+
{"/foo/bar", "/../../../../../../foo/bar/baz", "/baz"},
108+
{"/foo/bar/../baz", "/foo/baz/bar", "/bar"},
109+
{"/foo/bar/../baz", "/foo/baz/../bar/../baz/./foo", "/foo"},
110+
// All paths are made absolute before stripping.
111+
{"foo/bar", "/foo/bar/baz/bee", "/baz/bee"},
112+
{"/foo/bar", "foo/bar/baz/beef", "/baz/beef"},
113+
{"foo/bar", "foo/bar/baz/beets", "/baz/beets"},
114+
} {
115+
got := LexicallyStripRoot(test.root, test.path)
116+
if got != test.out {
117+
t.Errorf("LexicallyStripRoot(%q, %q) -- got %q, expected %q", test.root, test.path, got, test.out)
118+
}
119+
}
120+
}

internal/pathrs/root_pathrslite.go

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,7 @@
1919
package pathrs
2020

2121
import (
22-
"fmt"
2322
"os"
24-
"path/filepath"
2523

2624
"github.com/cyphar/filepath-securejoin/pathrs-lite"
2725
"golang.org/x/sys/unix"
@@ -50,12 +48,7 @@ func OpenInRoot(root, subpath string, flags int) (*os.File, error) {
5048
// include it in the passed flags. The fileMode argument uses unix.* mode bits,
5149
// *not* os.FileMode.
5250
func CreateInRoot(root, subpath string, flags int, fileMode uint32) (*os.File, error) {
53-
dir, filename := filepath.Split(subpath)
54-
if filepath.Join("/", filename) == "/" {
55-
return nil, fmt.Errorf("create in root subpath %q has bad trailing component %q", subpath, filename)
56-
}
57-
58-
dirFd, err := MkdirAllInRootOpen(root, dir, 0o755)
51+
dirFd, filename, err := MkdirAllParentInRoot(root, subpath, 0o755)
5952
if err != nil {
6053
return nil, err
6154
}

libcontainer/apparmor/apparmor_linux.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"golang.org/x/sys/unix"
1010

1111
"github.com/opencontainers/runc/internal/pathrs"
12-
"github.com/opencontainers/runc/libcontainer/utils"
1312
)
1413

1514
var (
@@ -29,7 +28,7 @@ func isEnabled() bool {
2928
}
3029

3130
func setProcAttr(attr, value string) error {
32-
attr = utils.CleanPath(attr)
31+
attr = pathrs.LexicallyCleanPath(attr)
3332
attrSubPath := "attr/apparmor/" + attr
3433
if _, err := os.Stat("/proc/self/" + attrSubPath); errors.Is(err, os.ErrNotExist) {
3534
// fall back to the old convention

libcontainer/criu_linux.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"google.golang.org/protobuf/proto"
2626

2727
"github.com/opencontainers/cgroups"
28+
"github.com/opencontainers/runc/internal/pathrs"
2829
"github.com/opencontainers/runc/libcontainer/configs"
2930
"github.com/opencontainers/runc/libcontainer/utils"
3031
)
@@ -555,16 +556,22 @@ func (c *Container) prepareCriuRestoreMounts(mounts []*configs.Mount) error {
555556
umounts := []string{}
556557
defer func() {
557558
for _, u := range umounts {
558-
_ = utils.WithProcfd(c.config.Rootfs, u, func(procfd string) error {
559-
if e := unix.Unmount(procfd, unix.MNT_DETACH); e != nil {
560-
if e != unix.EINVAL {
559+
mntFile, err := pathrs.OpenInRoot(c.config.Rootfs, u, unix.O_PATH)
560+
if err != nil {
561+
logrus.Warnf("Error during cleanup unmounting %s: open handle: %v", u, err)
562+
continue
563+
}
564+
_ = utils.WithProcfdFile(mntFile, func(procfd string) error {
565+
if err := unix.Unmount(procfd, unix.MNT_DETACH); err != nil {
566+
if err != unix.EINVAL {
561567
// Ignore EINVAL as it means 'target is not a mount point.'
562568
// It probably has already been unmounted.
563-
logrus.Warnf("Error during cleanup unmounting of %s (%s): %v", procfd, u, e)
569+
logrus.Warnf("Error during cleanup unmounting of %s (%s): %v", procfd, u, err)
564570
}
565571
}
566572
return nil
567573
})
574+
_ = mntFile.Close()
568575
}
569576
}()
570577
// Now go through all mounts and create the required mountpoints.

libcontainer/factory_linux.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ import (
1111

1212
"github.com/opencontainers/cgroups"
1313
"github.com/opencontainers/cgroups/manager"
14+
"github.com/opencontainers/runc/internal/pathrs"
1415
"github.com/opencontainers/runc/libcontainer/configs"
1516
"github.com/opencontainers/runc/libcontainer/configs/validate"
1617
"github.com/opencontainers/runc/libcontainer/intelrdt"
17-
"github.com/opencontainers/runc/libcontainer/utils"
1818
)
1919

2020
const (
@@ -211,7 +211,7 @@ func validateID(id string) error {
211211

212212
}
213213

214-
if string(os.PathSeparator)+id != utils.CleanPath(string(os.PathSeparator)+id) {
214+
if string(os.PathSeparator)+id != pathrs.LexicallyCleanPath(string(os.PathSeparator)+id) {
215215
return ErrInvalidID
216216
}
217217

0 commit comments

Comments
 (0)