local: fix dir metadata escaping the root through a planted symlink GHSA-f8g7-2xjc-7mfh CVE-PENDING
With -l/--links the local backend faithfully recreates a source ".rclonelink" as a real symlink at the destination. Directory metadata (chmod/chown/chtimes), however, was applied with the raw following syscalls os.Chmod/os.Chown/os.Chtimes rather than through the os.Root sandbox used for content writes. A Directory is never a translatedLink, so when the destination path already existed as a symlink planted by an untrusted source, the metadata was applied through it to a target outside the backup destination. Route directory metadata through os.Root when translating symlinks, so a planted symlink can no longer redirect chmod/chown/chtimes out of the destination, while legitimate in-tree directories are unaffected.
This commit is contained in:
+40
-1
@@ -1318,7 +1318,7 @@ func (o *Object) setTimes(atime, mtime time.Time) (err error) {
|
||||
if o.translatedLink {
|
||||
err = lChtimes(o.path, atime, mtime)
|
||||
} else {
|
||||
err = os.Chtimes(o.path, atime, mtime)
|
||||
err = o.fs.chtimes(o.path, atime, mtime)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -1568,6 +1568,45 @@ func (f *Fs) symlink(target, localPath string) (err error) {
|
||||
return root.Symlink(target, rel)
|
||||
}
|
||||
|
||||
// chmod changes the mode of localPath.
|
||||
func (f *Fs) chmod(localPath string, mode os.FileMode) (err error) {
|
||||
if !f.opt.TranslateSymlinks {
|
||||
return os.Chmod(localPath, mode)
|
||||
}
|
||||
root, rel, err := f.osRoot(localPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fs.CheckClose(root, &err)
|
||||
return root.Chmod(rel, mode)
|
||||
}
|
||||
|
||||
// chown changes the ownership of localPath.
|
||||
func (f *Fs) chown(localPath string, uid, gid int) (err error) {
|
||||
if !f.opt.TranslateSymlinks {
|
||||
return os.Chown(localPath, uid, gid)
|
||||
}
|
||||
root, rel, err := f.osRoot(localPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fs.CheckClose(root, &err)
|
||||
return root.Chown(rel, uid, gid)
|
||||
}
|
||||
|
||||
// chtimes changes the atime and mtime of localPath.
|
||||
func (f *Fs) chtimes(localPath string, atime, mtime time.Time) (err error) {
|
||||
if !f.opt.TranslateSymlinks {
|
||||
return os.Chtimes(localPath, atime, mtime)
|
||||
}
|
||||
root, rel, err := f.osRoot(localPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fs.CheckClose(root, &err)
|
||||
return root.Chtimes(rel, atime, mtime)
|
||||
}
|
||||
|
||||
// mkdirAll makes all the directories needed to store the object
|
||||
func (o *Object) mkdirAll() error {
|
||||
return o.fs.mkdirAll(filepath.Dir(o.path))
|
||||
|
||||
@@ -356,6 +356,97 @@ func TestSymlinkInTreeWriteThroughWorks(t *testing.T) {
|
||||
require.Equal(t, "world", string(got))
|
||||
}
|
||||
|
||||
// TestDirMetadataThroughPlantedSymlinkBlocked checks metadata +
|
||||
// symlinks can't write outside the root.
|
||||
func TestDirMetadataThroughPlantedSymlinkBlocked(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlinks and unix modes not applicable on Windows")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
// A directory outside the destination whose metadata the attacker targets.
|
||||
evil := t.TempDir()
|
||||
evilDir := filepath.Join(evil, "secret.d")
|
||||
require.NoError(t, os.Mkdir(evilDir, 0700))
|
||||
evilMtime := fstest.Time("2016-06-07T08:09:10Z")
|
||||
require.NoError(t, os.Chtimes(evilDir, evilMtime, evilMtime))
|
||||
|
||||
r := fstest.NewRun(t)
|
||||
f := r.Flocal.(*Fs)
|
||||
linksMode(f)
|
||||
|
||||
// The planted symlink dst/pwn -> outside, faithfully reproduced by --links.
|
||||
require.NoError(t, putLink(ctx, f, "pwn", evilDir))
|
||||
|
||||
// The source now presents "pwn" as a directory with attacker-chosen mode
|
||||
// and mtime. Applying it must not reach through the planted symlink.
|
||||
metadata := fs.Metadata{
|
||||
"mode": "0777",
|
||||
"mtime": "2001-02-03T04:05:06Z",
|
||||
}
|
||||
_, err := f.MkdirMetadata(ctx, "pwn", metadata)
|
||||
require.Error(t, err, "applying metadata through a planted symlink should be refused")
|
||||
|
||||
// The outside directory's mode and mtime must be unchanged.
|
||||
fi, err := os.Stat(evilDir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, os.FileMode(0700), fi.Mode().Perm(), "chmod escaped through planted symlink to %q", evilDir)
|
||||
require.True(t, fi.ModTime().Equal(evilMtime), "chtimes escaped through planted symlink to %q", evilDir)
|
||||
}
|
||||
|
||||
// TestDirSetModTimeThroughPlantedSymlinkBlocked checks we can't
|
||||
// chtimes outside the root with --links
|
||||
func TestDirSetModTimeThroughPlantedSymlinkBlocked(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlinks not applicable on Windows")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
evil := t.TempDir()
|
||||
evilDir := filepath.Join(evil, "secret.d")
|
||||
require.NoError(t, os.Mkdir(evilDir, 0700))
|
||||
evilMtime := fstest.Time("2016-06-07T08:09:10Z")
|
||||
require.NoError(t, os.Chtimes(evilDir, evilMtime, evilMtime))
|
||||
|
||||
r := fstest.NewRun(t)
|
||||
f := r.Flocal.(*Fs)
|
||||
linksMode(f)
|
||||
|
||||
require.NoError(t, putLink(ctx, f, "pwn", evilDir))
|
||||
|
||||
err := f.DirSetModTime(ctx, "pwn", fstest.Time("2001-02-03T04:05:06Z"))
|
||||
require.Error(t, err, "setting dir modtime through a planted symlink should be refused")
|
||||
|
||||
fi, err := os.Stat(evilDir)
|
||||
require.NoError(t, err)
|
||||
require.True(t, fi.ModTime().Equal(evilMtime), "chtimes escaped through planted symlink to %q", evilDir)
|
||||
}
|
||||
|
||||
// TestDirMetadataInTreeWorks checks the root confinement doesn't
|
||||
// break legitimate dir metadata.
|
||||
func TestDirMetadataInTreeWorks(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("unix modes not applicable on Windows")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
r := fstest.NewRun(t)
|
||||
f := r.Flocal.(*Fs)
|
||||
linksMode(f)
|
||||
|
||||
metadata := fs.Metadata{
|
||||
"mode": "0705",
|
||||
"mtime": "2001-02-03T04:05:06Z",
|
||||
}
|
||||
_, err := f.MkdirMetadata(ctx, "sub", metadata)
|
||||
require.NoError(t, err)
|
||||
|
||||
fi, err := os.Stat(filepath.Join(f.root, "sub"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, os.FileMode(0705), fi.Mode().Perm())
|
||||
require.True(t, fi.ModTime().Equal(fstest.Time("2001-02-03T04:05:06Z")))
|
||||
}
|
||||
|
||||
// TestEncodingEscapeBlocked checks that a name from a malicious source can't
|
||||
// be decoded into path syntax which writes outside the destination.
|
||||
func TestEncodingEscapeBlocked(t *testing.T) {
|
||||
|
||||
@@ -128,7 +128,7 @@ func (o *Object) writeMetadataToFile(m fs.Metadata) (outErr error) {
|
||||
if o.translatedLink {
|
||||
err = os.Lchown(o.path, uid, gid)
|
||||
} else {
|
||||
err = os.Chown(o.path, uid, gid)
|
||||
err = o.fs.chown(o.path, uid, gid)
|
||||
}
|
||||
if err != nil {
|
||||
outErr = fmt.Errorf("failed to change ownership: %w", err)
|
||||
@@ -155,7 +155,7 @@ func (o *Object) writeMetadataToFile(m fs.Metadata) (outErr error) {
|
||||
err = nil
|
||||
}
|
||||
} else {
|
||||
err = os.Chmod(o.path, fileMode)
|
||||
err = o.fs.chmod(o.path, fileMode)
|
||||
}
|
||||
if err != nil {
|
||||
outErr = fmt.Errorf("failed to change permissions: %w", err)
|
||||
|
||||
Reference in New Issue
Block a user