Skip to content

Commit 90adf5c

Browse files
committed
mkdir: do not return errors for incorrect directory modes or owners
We've had several examples of unexpected semantics with how modes are calculated, and there will likely be many more in the future. In addition, mounting filesystems like vfat with mount options that mess with ownership (like "uid=1234,gid=5678,umask=0") will result in unexpected behaviour that would be very difficult to emulate. To avoid further regressions, just remove the checks entirely. In theory we could switch to adding warnings, but there's no real benefit IMHO. The semantics of MkdirAll are quite loose already so arguably there is no practical difference between re-using a directory that already existed and being tricked into opening an intermediate directory you didn't create. Signed-off-by: Aleksa Sarai <cyphar@cyphar.com>
1 parent 3bf6419 commit 90adf5c

File tree

4 files changed

+42
-86
lines changed

4 files changed

+42
-86
lines changed

CHANGELOG.md

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

77
## [Unreleased] ##
88

9+
### Fixed ###
10+
- The mode and owner verification logic in `MkdirAll` has been removed. This
11+
was originally intended to protect against some theoretical attacks but upon
12+
further consideration these protections don't actually buy us anything and
13+
they were causing spurious errors with more complicated filesystem setups.
14+
915
## [0.3.2] - 2024-09-13 ##
1016

1117
### Changed ###

mkdir_linux.go

Lines changed: 25 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -108,35 +108,6 @@ func MkdirAllHandle(root *os.File, unsafePath string, mode int) (_ *os.File, Err
108108

109109
// Make sure the mode doesn't have any type bits.
110110
mode &^= unix.S_IFMT
111-
// What properties do we expect any newly created directories to have?
112-
var (
113-
// While umask(2) is a per-thread property, and thus this value could
114-
// vary between threads, a functioning Go program would LockOSThread
115-
// threads with different umasks and so we don't need to LockOSThread
116-
// for this entire mkdirat loop (if we are in the locked thread with a
117-
// different umask, we are already locked and there's nothing for us to
118-
// do -- and if not then it doesn't matter which thread we run on and
119-
// there's nothing for us to do).
120-
expectedMode = uint32(unix.S_IFDIR | (mode &^ getUmask()))
121-
122-
// We would want to get the fs[ug]id here, but we can't access those
123-
// from userspace. In practice, nobody uses setfs[ug]id() anymore, so
124-
// just use the effective [ug]id (which is equivalent to the fs[ug]id
125-
// for programs that don't use setfs[ug]id).
126-
expectedUid = uint32(unix.Geteuid())
127-
expectedGid = uint32(unix.Getegid())
128-
)
129-
130-
// The setgid bit (S_ISGID = 0o2000) is inherited to child directories and
131-
// affects the group of any inodes created in said directory, so if the
132-
// starting directory has it set we need to adjust our expected mode and
133-
// owner to match.
134-
if st, err := fstatFile(currentDir); err != nil {
135-
return nil, fmt.Errorf("failed to stat starting path for mkdir %q: %w", currentDir.Name(), err)
136-
} else if st.Mode&unix.S_ISGID == unix.S_ISGID {
137-
expectedMode |= unix.S_ISGID
138-
expectedGid = st.Gid
139-
}
140111

141112
// Create the remaining components.
142113
for _, part := range remainingParts {
@@ -175,35 +146,33 @@ func MkdirAllHandle(root *os.File, unsafePath string, mode int) (_ *os.File, Err
175146
_ = currentDir.Close()
176147
currentDir = nextDir
177148

178-
// Make sure that the directory matches what we expect. An attacker
179-
// could have swapped the directory between us making it and opening
180-
// it. There's no way for us to be sure that the directory is
181-
// _precisely_ the same as the directory we created, but if we are in
182-
// an empty directory with the same owner and mode as the one we
183-
// created then there is nothing the attacker could do with this new
184-
// directory that they couldn't do with the old one.
185-
if stat, err := fstat(currentDir); err != nil {
186-
return nil, fmt.Errorf("check newly created directory: %w", err)
187-
} else {
188-
if stat.Mode != expectedMode {
189-
return nil, fmt.Errorf("%w: newly created directory %q has incorrect mode 0o%.3o (expected 0o%.3o)", errPossibleAttack, currentDir.Name(), stat.Mode, expectedMode)
190-
}
191-
if stat.Uid != expectedUid || stat.Gid != expectedGid {
192-
return nil, fmt.Errorf("%w: newly created directory %q has incorrect owner %d:%d (expected %d:%d)", errPossibleAttack, currentDir.Name(), stat.Uid, stat.Gid, expectedUid, expectedGid)
193-
}
194-
// Check that the directory is empty. We only need to check for
195-
// a single entry, and we should get EOF if the directory is
196-
// empty.
197-
_, err := currentDir.Readdirnames(1)
198-
if !errors.Is(err, io.EOF) {
199-
if err == nil {
200-
err = fmt.Errorf("%w: newly created directory %q is non-empty", errPossibleAttack, currentDir.Name())
201-
}
202-
return nil, fmt.Errorf("check if newly created directory %q is empty: %w", currentDir.Name(), err)
149+
// Try our best to check that the directory is empty and so is unlikely
150+
// to have been swapped by an attacker.
151+
//
152+
// Ideally we would also check that the owner and mode match what we
153+
// would've created -- unfortunately, it is non-trivial to verify that
154+
// the owner and mode of the created directory match. While plain Unix
155+
// DAC rules seem simple enough to emulate, there are a bunch of other
156+
// factors that can change the mode or owner of created directories
157+
// (default POSIX ACLs, mount options like uid=1,gid=2,umask=0 on
158+
// filesystems like vfat, etc etc). We used to try to verify this but
159+
// it just lead to a series of spurious errors.
160+
//
161+
// To be honest, since MkdirAll allows you to use existing directories,
162+
// the practical scope of this protection seems very limited (if it
163+
// even exists) so it really isn't that important.
164+
165+
// We only need to check for a single entry to see if it's empty, and
166+
// we should get EOF if the directory is empty.
167+
_, err := currentDir.Readdirnames(1)
168+
if !errors.Is(err, io.EOF) {
169+
if err == nil {
170+
err = fmt.Errorf("%w: newly created directory %q is non-empty", errPossibleAttack, currentDir.Name())
203171
}
204-
// Reset the offset.
205-
_, _ = currentDir.Seek(0, unix.SEEK_SET)
172+
return nil, fmt.Errorf("check if newly created directory %q is empty: %w", currentDir.Name(), err)
206173
}
174+
// Reset the offset.
175+
_, _ = currentDir.Seek(0, unix.SEEK_SET)
207176
}
208177
return currentDir, nil
209178
}

mkdir_linux_test.go

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -314,12 +314,9 @@ func (m *racingMkdirMeta) checkMkdirAllHandle_Racing(t *testing.T, root, unsafeP
314314
}
315315
defer handle.Close()
316316

317-
// Make sure the handle has the right owner/mode.
318-
unixStat, err := fstat(handle)
319-
require.NoError(t, err, "stat mkdirall handle")
320-
assert.Equal(t, uint32(unix.S_IFDIR|mode), unixStat.Mode, "mkdirall handle mode")
321-
assert.Equal(t, uint32(unix.Geteuid()), unixStat.Uid, "mkdirall handle uid")
322-
assert.Equal(t, uint32(unix.Getegid()), unixStat.Gid, "mkdirall handle gid")
317+
// It's possible for an attacker to have swapped the final directory, but
318+
// this is okay because MkdirAll will use pre-existing directories anyway.
319+
// So there's no need to check the returned handle.
323320
// TODO: Does it make sense to even try to check the handle path?
324321
m.passOkCount++
325322
}
@@ -346,8 +343,8 @@ func TestMkdirAllHandle_RacingRename(t *testing.T) {
346343

347344
tests := []test{
348345
{"good", "target/a/b/c/d/e", "swapdir-empty-ok", "target/a/b/c/d/e/f/g/h/i/j/k", nil},
349-
{"trailing", "target/a/b/c/d/e", "swapdir-empty-badmode", "target/a/b/c/d/e", []error{errPossibleAttack}},
350-
{"partial", "target/a/b/c/d/e", "swapdir-empty-badmode", "target/a/b/c/d/e/f/g/h/i/j/k", []error{errPossibleAttack}},
346+
{"trailing", "target/a/b/c/d/e", "swapdir-empty-badmode", "target/a/b/c/d/e", nil},
347+
{"partial", "target/a/b/c/d/e", "swapdir-empty-badmode", "target/a/b/c/d/e/f/g/h/i/j/k", nil},
351348
{"trailing", "target/a/b/c/d/e", "swapdir-nonempty1", "target/a/b/c/d/e", []error{errPossibleAttack}},
352349
{"partial", "target/a/b/c/d/e", "swapdir-nonempty1", "target/a/b/c/d/e/f/g/h/i/j/k", []error{errPossibleAttack}},
353350
{"trailing", "target/a/b/c/d/e", "swapdir-nonempty2", "target/a/b/c/d/e", []error{errPossibleAttack}},
@@ -364,12 +361,12 @@ func TestMkdirAllHandle_RacingRename(t *testing.T) {
364361
"dir swapdir-empty-badowner3 111:222:0711",
365362
)
366363
tests = append(tests, []test{
367-
{"trailing", "target/a/b/c/d/e", "swapdir-empty-badowner1", "target/a/b/c/d/e", []error{errPossibleAttack}},
368-
{"partial", "target/a/b/c/d/e", "swapdir-empty-badowner1", "target/a/b/c/d/e/f/g/h/i/j/k", []error{errPossibleAttack}},
369-
{"trailing", "target/a/b/c/d/e", "swapdir-empty-badowner2", "target/a/b/c/d/e", []error{errPossibleAttack}},
370-
{"partial", "target/a/b/c/d/e", "swapdir-empty-badowner2", "target/a/b/c/d/e/f/g/h/i/j/k", []error{errPossibleAttack}},
371-
{"trailing", "target/a/b/c/d/e", "swapdir-empty-badowner3", "target/a/b/c/d/e", []error{errPossibleAttack}},
372-
{"partial", "target/a/b/c/d/e", "swapdir-empty-badowner3", "target/a/b/c/d/e/f/g/h/i/j/k", []error{errPossibleAttack}},
364+
{"trailing", "target/a/b/c/d/e", "swapdir-empty-badowner1", "target/a/b/c/d/e", nil},
365+
{"partial", "target/a/b/c/d/e", "swapdir-empty-badowner1", "target/a/b/c/d/e/f/g/h/i/j/k", nil},
366+
{"trailing", "target/a/b/c/d/e", "swapdir-empty-badowner2", "target/a/b/c/d/e", nil},
367+
{"partial", "target/a/b/c/d/e", "swapdir-empty-badowner2", "target/a/b/c/d/e/f/g/h/i/j/k", nil},
368+
{"trailing", "target/a/b/c/d/e", "swapdir-empty-badowner3", "target/a/b/c/d/e", nil},
369+
{"partial", "target/a/b/c/d/e", "swapdir-empty-badowner3", "target/a/b/c/d/e/f/g/h/i/j/k", nil},
373370
}...)
374371
}
375372

procfs_linux.go

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -411,22 +411,6 @@ func isDeadInode(file *os.File) error {
411411
return nil
412412
}
413413

414-
func getUmask() int {
415-
// umask is a per-thread property, but it is inherited by children, so we
416-
// need to lock our OS thread to make sure that no other goroutine runs in
417-
// this thread and no goroutines are spawned from this thread until we
418-
// revert to the old umask.
419-
//
420-
// We could parse /proc/self/status to avoid this get-set problem, but
421-
// /proc/thread-self requires LockOSThread anyway, so there's no real
422-
// benefit over just using umask(2).
423-
runtime.LockOSThread()
424-
umask := unix.Umask(0)
425-
unix.Umask(umask)
426-
runtime.UnlockOSThread()
427-
return umask
428-
}
429-
430414
func checkProcSelfFdPath(path string, file *os.File) error {
431415
if err := isDeadInode(file); err != nil {
432416
return err

0 commit comments

Comments
 (0)