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:
Nick Craig-Wood
2026-09-04 19:00:22 +01:00
parent 142172c21f
commit 29bb1e1134
3 changed files with 133 additions and 3 deletions
+40 -1
View File
@@ -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))
+91
View File
@@ -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) {
+2 -2
View File
@@ -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)