Skip to content

Commit 5e78ca5

Browse files
committed
merge #52 into cyphar/filepath-securejoin:main
Aleksa Sarai (7): proc: add pure-resolver sanity tests proc: export ProcThreadSelf and ProcSelfFdReadlink proc: switch to much safer resolver for non-openat2 systems proc: split verifyProcRoot proc: checkSymlinkOvermount -> checkSubpathOvermount proc: add more info for statx failures tests: reduce number of rounds for race tests LGTMs: cyphar
2 parents f52dfb2 + 1a81a8a commit 5e78ca5

10 files changed

+565
-122
lines changed

CHANGELOG.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,80 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
66

77
## [Unreleased] ##
88

9+
### Added ###
10+
- Some minimal parts of the safe `procfs` API have now been exposed. At the
11+
moment these include:
12+
13+
* `ProcThreadSelf` which allows you to get a safe handle `O_PATH` to a
14+
subpath in `/proc/thread-self` (you can upgrade it to a proper handle with
15+
`Reopen` like any other `O_PATH` handle). The returned
16+
`ProcThreadSelfCloser` needs to be called after you completely finish
17+
using the handle (this is necessary because Go is multi-threaded and
18+
`ProcThreadSelf` references `/proc/thread-self` which may disappear if we
19+
do not `runtime.LockOSThread` -- `ProcThreadSelfCloser` is currently
20+
equivalent to `runtime.UnlockOSThread`).
21+
22+
Note that you cannot operate on any `procfs` symlinks (most notably
23+
magiclinks) using this API. At the moment `filepath-securejoin` does not
24+
support this feature (but [libpathrs][] does).
25+
26+
* `ProcSelfFdReadlink` lets you get the in-kernel path representation of a
27+
file descriptor (think `readlink("/proc/self/fd/...")`). This is
28+
equivalent to doing a `readlinkat(fd, "", ...)` of
29+
`ProcThreadSelf("fd/%d")`, except that we verify that there aren't any
30+
tricky overmounts that could fool the process.
31+
32+
Please be aware that the returned string is simply a snapshot at that
33+
particular moment, and an attacker could move the file being pointed to.
34+
In addition, complex namespace configurations could result in non-sensical
35+
or confusing paths to be returned. The value received from this function
36+
should only be used as secondary verification of some security property,
37+
not as proof that a particular handle has a particular path.
38+
39+
The procfs handle used internally by the API is the same as the rest of
40+
`filepath-securejoin` (for privileged programs this is usually a private
41+
in-process `procfs` instance created with `fsopen(2)`).
42+
43+
As before, this is intended as a stop-gap before users migrate to
44+
[libpathrs][], which provides a far more extensive safe `procfs` API and is
45+
generally more robust.
46+
47+
- Previously, the hardened procfs implementation (used internally within
48+
`Reopen` and `Open(at)InRoot`) only protected against overmount attacks on
49+
systems with `openat2(2)` (Linux 5.6) or systems with `fsopen(2)` or
50+
`open_tree(2)` (Linux 4.18) and programs with privileges to use them (with
51+
some caveats about locked mounts that probably affect very few users). For
52+
other users, an attacker with the ability to create malicious mounts (on most
53+
systems, a sysadmin) could trick you into operating on files you didn't
54+
expect. This attack only really makes sense in the context of container
55+
runtime implementations.
56+
57+
This was considered a reasonable trade-off, as the long-term intention was to
58+
get all users to just switch to [libpathrs][] if they wanted to use the safe
59+
`procfs` API (which had more extensive protections, and is what these new
60+
protections in `filepath-securejoin` are based on). However, as the API
61+
is now being exported it seems unwise to advertise the API as "safe" if we do
62+
not protect against known attacks.
63+
64+
The procfs API will now be more protected against attackers on systems
65+
lacking the aforementioned protections. However, the most comprehensive of
66+
these protections effectively rely on [`statx(STATX_MNT_ID)`][statx.2] (Linux
67+
5.8). On older kernel versions, there is no effective protection (there is
68+
some minimal protection against non-`procfs` filesystem components but a
69+
sufficiently clever attacker can work around those). In addition,
70+
`STATX_MNT_ID` is vulnerable to mount ID reuse attacks by sufficiently
71+
motivated and privileged attackers -- this problem is mitigated with
72+
`STATX_MNT_ID_UNIQUE` (Linux 6.8) but that raises the minimum kernel version
73+
for more protection.
74+
75+
The fact that these protections are quite limited despite needing a fair bit
76+
of extra code to handle was one of the primary reasons we did not initially
77+
implement this in `filepath-securejoin` ([libpathrs][] supports all of this,
78+
of course).
79+
80+
[libpathrs]: https://github.com/openSUSE/libpathrs
81+
[statx.2]: https://www.man7.org/linux/man-pages/man2/statx.2.html
82+
983
## [0.4.1] - 2025-01-28 ##
1084

1185
### Fixed ###

lookup_linux.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ func lookupInRoot(root *os.File, unsafePath string, partial bool) (Handle *os.Fi
190190
// root is some magic-link like /proc/$pid/root, in which case we want to
191191
// make sure when we do checkProcSelfFdPath that we are using the correct
192192
// root path.
193-
logicalRootPath, err := procSelfFdReadlink(root)
193+
logicalRootPath, err := ProcSelfFdReadlink(root)
194194
if err != nil {
195195
return nil, "", fmt.Errorf("get real root path: %w", err)
196196
}

lookup_linux_test.go

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func checkPartialLookup(t *testing.T, partialLookupFn partialLookupFunc, rootDir
5252
assert.Equal(t, expected.remainingPath, remainingPath, "remaining path")
5353

5454
// Check the handle path.
55-
gotPath, err := procSelfFdReadlink(handle)
55+
gotPath, err := ProcSelfFdReadlink(handle)
5656
require.NoError(t, err, "get real path of returned handle")
5757
assert.Equal(t, expected.handlePath, gotPath, "real handle path")
5858
// Make sure the handle matches the readlink path.
@@ -371,9 +371,9 @@ func (m *racingLookupMeta) checkPartialLookup(t *testing.T, rootDir *os.File, un
371371
if handle != nil {
372372
handleName = handle.Name()
373373

374-
// Get the "proper" name from procSelfFdReadlink.
374+
// Get the "proper" name from ProcSelfFdReadlink.
375375
m.pauseCh <- struct{}{}
376-
realPath, err = procSelfFdReadlink(handle)
376+
realPath, err = ProcSelfFdReadlink(handle)
377377
<-m.pauseCh
378378
require.NoError(t, err, "get real path of returned handle")
379379

@@ -558,20 +558,34 @@ func TestPartialLookup_RacingRename(t *testing.T) {
558558
go doRenameExchangeLoop(pauseCh, exitCh, rootDir, test.subPathA, test.subPathB)
559559

560560
// Do several runs to try to catch bugs.
561-
const testRuns = 50000
561+
const (
562+
testRuns = 3000
563+
minPassCount = 10
564+
)
562565
m := newRacingLookupMeta(pauseCh)
563-
for i := 0; i < testRuns; i++ {
566+
doneRuns := 0
567+
for ; doneRuns < testRuns || m.passOkCount < minPassCount; doneRuns++ {
564568
m.checkPartialLookup(t, rootDir, test.unsafePath, test.skipErrs, test.allowedResults)
569+
// Make sure we don't infinite loop here.
570+
if doneRuns >= 50*testRuns {
571+
break
572+
}
565573
}
566574

567575
pct := func(count int) string {
568-
return fmt.Sprintf("%d(%.3f%%)", count, 100.0*float64(count)/float64(testRuns))
576+
return fmt.Sprintf("%d(%.3f%%)", count, 100.0*float64(count)/float64(doneRuns))
577+
}
578+
579+
// No passing runs is a bit unfortunate, but some of our tests
580+
// can do that and failing here would just lead to flaky tests.
581+
if m.passOkCount == 0 {
582+
t.Logf("WARNING: NO PASSING RUNS!")
569583
}
570584

571585
// Output some stats.
572586
t.Logf("after %d runs: passOk=%s passErr=%s skip=%s fail=%s (+badErr=%s)",
573587
// runs and breakdown of path-related (pass, fail) as well as skipped runs
574-
testRuns, pct(m.passOkCount), pct(m.passErrCount), pct(m.skipCount), pct(m.failCount),
588+
doneRuns, pct(m.passOkCount), pct(m.passErrCount), pct(m.skipCount), pct(m.failCount),
575589
// failures due to incorrect errors (rather than bad paths)
576590
pct(m.badErrCount))
577591
t.Logf(" badHandleName=%s fixRemainingPath=%s",

mkdir_linux_test.go

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ var mkdirAll_MkdirAllHandle mkdirAllFunc = func(t *testing.T, root, unsafePath s
4646
require.NoError(t, err)
4747

4848
// Now double-check that the handle is correct.
49-
gotPath, err := procSelfFdReadlink(handle)
49+
gotPath, err := ProcSelfFdReadlink(handle)
5050
require.NoError(t, err, "get real path of returned handle")
5151
assert.Equal(t, expectedPath, gotPath, "wrong final path from MkdirAllHandle")
5252
// Also check that the f.Name() is correct while we're at it (this is
@@ -404,9 +404,13 @@ func TestMkdirAllHandle_RacingRename(t *testing.T) { //nolint:revive // undersco
404404
}(rootCh)
405405

406406
// Do several runs to try to catch bugs.
407-
const testRuns = 2000
407+
const (
408+
testRuns = 800
409+
minPassCount = 10
410+
)
408411
m := newRacingMkdirMeta()
409-
for i := 0; i < testRuns; i++ {
412+
doneRuns := 0
413+
for ; doneRuns < testRuns || m.passOkCount < minPassCount; doneRuns++ {
410414
root := createTree(t, treeSpec...)
411415

412416
rootCh <- root
@@ -417,15 +421,26 @@ func TestMkdirAllHandle_RacingRename(t *testing.T) { //nolint:revive // undersco
417421
// Clean up the root after each run so we don't exhaust all
418422
// space in the tmpfs.
419423
_ = os.RemoveAll(root)
424+
425+
// Make sure we don't infinite loop here.
426+
if doneRuns >= 50*testRuns {
427+
break
428+
}
420429
}
421430

422431
pct := func(count int) string {
423-
return fmt.Sprintf("%d(%.3f%%)", count, 100.0*float64(count)/float64(testRuns))
432+
return fmt.Sprintf("%d(%.3f%%)", count, 100.0*float64(count)/float64(doneRuns))
433+
}
434+
435+
// No passing runs is a bit unfortunate, but some of our tests
436+
// can do that and failing here would just lead to flaky tests.
437+
if m.passOkCount == 0 {
438+
t.Logf("WARNING: NO PASSING RUNS!")
424439
}
425440

426441
// Output some stats.
427442
t.Logf("after %d runs: passOk=%s passErr=%s fail=%s",
428-
testRuns, pct(m.passOkCount), pct(m.passErrCount), pct(m.failCount))
443+
doneRuns, pct(m.passOkCount), pct(m.passErrCount), pct(m.failCount))
429444
if len(m.passErrCounts) > 0 {
430445
t.Logf(" passErr breakdown:")
431446
for err, count := range m.passErrCounts {
@@ -475,9 +490,13 @@ func TestMkdirAllHandle_RacingDelete(t *testing.T) { //nolint:revive // undersco
475490
}(rootCh)
476491

477492
// Do several runs to try to catch bugs.
478-
const testRuns = 2000
493+
const (
494+
testRuns = 800
495+
minPassCount = 10
496+
)
479497
m := newRacingMkdirMeta()
480-
for i := 0; i < testRuns; i++ {
498+
doneRuns := 0
499+
for ; doneRuns < testRuns; doneRuns++ {
481500
root := createTree(t, treeSpec...)
482501

483502
rootCh <- root
@@ -487,15 +506,26 @@ func TestMkdirAllHandle_RacingDelete(t *testing.T) { //nolint:revive // undersco
487506
// Clean up the root after each run so we don't exhaust all
488507
// space in the tmpfs.
489508
_ = os.RemoveAll(root + "/..")
509+
510+
// Make sure we don't infinite loop here.
511+
if doneRuns >= 50*testRuns {
512+
break
513+
}
490514
}
491515

492516
pct := func(count int) string {
493-
return fmt.Sprintf("%d(%.3f%%)", count, 100.0*float64(count)/float64(testRuns))
517+
return fmt.Sprintf("%d(%.3f%%)", count, 100.0*float64(count)/float64(doneRuns))
518+
}
519+
520+
// No passing runs is a bit unfortunate, but some of our tests
521+
// can do that and failing here would just lead to flaky tests.
522+
if m.passOkCount == 0 {
523+
t.Logf("WARNING: NO PASSING RUNS!")
494524
}
495525

496526
// Output some stats.
497527
t.Logf("after %d runs: passOk=%s passErr=%s fail=%s",
498-
testRuns, pct(m.passOkCount), pct(m.passErrCount), pct(m.failCount))
528+
doneRuns, pct(m.passOkCount), pct(m.passErrCount), pct(m.failCount))
499529
if len(m.passErrCounts) > 0 {
500530
t.Logf(" passErr breakdown:")
501531
for err, count := range m.passErrCounts {

open_linux.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ func Reopen(handle *os.File, flags int) (*os.File, error) {
8888
// [1]: Linux commit ee2e3f50629f ("mount: fix mounting of detached mounts
8989
// onto targets that reside on shared mounts").
9090
fdStr := strconv.Itoa(int(handle.Fd()))
91-
if err := checkSymlinkOvermount(procRoot, procFdDir, fdStr); err != nil {
91+
if err := checkSubpathOvermount(procRoot, procFdDir, fdStr); err != nil {
9292
return nil, fmt.Errorf("check safety of /proc/thread-self/fd/%s magiclink: %w", fdStr, err)
9393
}
9494

open_linux_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,13 @@ func checkReopen(t *testing.T, handle *os.File, flags int, expectedErr error) {
6262
require.NoError(t, err)
6363

6464
// Get the original handle path.
65-
handlePath, err := procSelfFdReadlink(handle)
65+
handlePath, err := ProcSelfFdReadlink(handle)
6666
require.NoError(t, err, "get real path of original handle")
6767
// Make sure the handle matches the readlink path.
6868
assert.Equal(t, handlePath, handle.Name(), "handle.Name() matching real original handle path")
6969

7070
// Check that the new and old handle have the same path.
71-
newHandlePath, err := procSelfFdReadlink(newHandle)
71+
newHandlePath, err := ProcSelfFdReadlink(newHandle)
7272
require.NoError(t, err, "get real path of reopened handle")
7373
assert.Equal(t, handlePath, newHandlePath, "old and reopen handle paths")
7474
assert.Equal(t, handle.Name(), newHandle.Name(), "old and reopen handle.Name()")
@@ -102,7 +102,7 @@ func checkOpenInRoot(t *testing.T, openInRootFn openInRootFunc, root, unsafePath
102102
require.NoError(t, err)
103103

104104
// Check the handle path.
105-
gotPath, err := procSelfFdReadlink(handle)
105+
gotPath, err := ProcSelfFdReadlink(handle)
106106
require.NoError(t, err, "get real path of returned handle")
107107
assert.Equal(t, expected.handlePath, gotPath, "real handle path")
108108
// Make sure the handle matches the readlink path.

0 commit comments

Comments
 (0)