local: stop --links symlinks escaping the destination directory CVE-2026-54572
With -l/--links rclone recreates a .rclonelink object as a symlink. A malicious or compromised source could serve a symlink whose target points outside the destination, plus a sibling object whose path traverses it, so that rclone followed the planted symlink and wrote outside the destination causing arbitrary file write. When translating symlinks, rclone now performs all destination writes (directory creation, file writes and symlink creation) through an os.Root anchored at the destination. os.Root resolves every path component relative to the destination's file descriptor and refuses any that escapes the root, even under concurrent modification, so a planted symlink can never be traversed out of the destination. Symlinks are still reproduced verbatim - including ones whose target points outside the destination - so backups remain faithful. Only writing *through* such a link is refused. In-tree symlinks are unaffected. Fixes CVE-2026-54572 Fixes GHSA-cf44-9pgv-m4xc
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -204,6 +205,140 @@ func TestSymlinkError(t *testing.T) {
|
||||
assert.Equal(t, errLinksAndCopyLinks, err)
|
||||
}
|
||||
|
||||
// putLink writes target as a translated link object (name + ".rclonelink") on f.
|
||||
func putLink(ctx context.Context, f fs.Fs, name, target string) error {
|
||||
in := bytes.NewBufferString(target)
|
||||
src := object.NewStaticObjectInfo(name+fs.LinkSuffix, fstest.Time("2001-02-03T04:05:10Z"), int64(len(target)), true, nil, nil)
|
||||
_, err := f.Put(ctx, in, src)
|
||||
return err
|
||||
}
|
||||
|
||||
// putFile writes content as a regular object on f.
|
||||
func putFile(ctx context.Context, f fs.Fs, remote, content string) error {
|
||||
in := bytes.NewBufferString(content)
|
||||
src := object.NewStaticObjectInfo(remote, fstest.Time("2001-02-03T04:05:10Z"), int64(len(content)), true, nil, nil)
|
||||
_, err := f.Put(ctx, in, src)
|
||||
return err
|
||||
}
|
||||
|
||||
// linksMode puts f into "-l/--links" mode, as if --links or the backend
|
||||
// links=true option were set.
|
||||
func linksMode(f *Fs) {
|
||||
f.opt.FollowSymlinks = false
|
||||
f.opt.TranslateSymlinks = true
|
||||
f.lstat = os.Lstat
|
||||
}
|
||||
|
||||
// TestSymlinkEscapeWriteThroughBlocked mirrors the GHSA-cf44-9pgv-m4xc PoC: a
|
||||
// malicious --links source serves "pwn.rclonelink" whose body is a path outside
|
||||
// the destination, plus a sibling "pwn/authkeys" that sorts after it and would
|
||||
// be written through the planted symlink. rclone reproduces the symlink (a
|
||||
// faithful backup of the source) but must refuse to write through it, so
|
||||
// nothing lands outside the destination (CWE-59).
|
||||
func TestSymlinkEscapeWriteThroughBlocked(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// A directory outside the destination the attacker wants to write into
|
||||
evil := t.TempDir()
|
||||
evilFile := filepath.Join(evil, "authkeys")
|
||||
|
||||
r := fstest.NewRun(t)
|
||||
f := r.Flocal.(*Fs)
|
||||
linksMode(f)
|
||||
|
||||
// The symlink is reproduced faithfully, pointing outside the destination.
|
||||
require.NoError(t, putLink(ctx, f, "pwn", evil))
|
||||
link := filepath.Join(f.root, "pwn")
|
||||
fi, err := os.Lstat(link)
|
||||
require.NoError(t, err)
|
||||
require.True(t, fi.Mode()&os.ModeSymlink != 0, "symlink should be reproduced faithfully")
|
||||
target, err := os.Readlink(link)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, evil, target)
|
||||
|
||||
// But writing the sibling object through it must be refused.
|
||||
err = putFile(ctx, f, "pwn/authkeys", "PWNED")
|
||||
require.Error(t, err, "writing through a planted symlink should be refused")
|
||||
|
||||
// Nothing escaped the destination.
|
||||
_, err = os.Stat(evilFile)
|
||||
require.True(t, os.IsNotExist(err), "a file escaped the destination into %q", evilFile)
|
||||
}
|
||||
|
||||
// TestSymlinkEscapeNestedBlocked covers the chained variant: an in-tree symlink
|
||||
// "evil" -> "." (the destination root) is created, then "evil/pwn" -> outside
|
||||
// is planted through it, then a write nested under that. Every component is
|
||||
// re-validated against the root, so the write-through is refused and nothing
|
||||
// escapes.
|
||||
func TestSymlinkEscapeNestedBlocked(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
evil := t.TempDir()
|
||||
evilFile := filepath.Join(evil, "authkeys")
|
||||
|
||||
r := fstest.NewRun(t)
|
||||
f := r.Flocal.(*Fs)
|
||||
linksMode(f)
|
||||
|
||||
require.NoError(t, putLink(ctx, f, "evil", "."))
|
||||
require.NoError(t, putLink(ctx, f, "evil/pwn", evil))
|
||||
|
||||
err := putFile(ctx, f, "evil/pwn/authkeys", "PWNED")
|
||||
require.Error(t, err, "writing through a nested planted symlink should be refused")
|
||||
|
||||
_, err = os.Stat(evilFile)
|
||||
require.True(t, os.IsNotExist(err), "a file escaped the destination into %q", evilFile)
|
||||
}
|
||||
|
||||
// TestSymlinkEscapeConcurrent races symlink creation against the sibling write
|
||||
// for many pairs at once, exercising the time-of-check/time-of-use window.
|
||||
// os.Root resolves relative to a directory file descriptor, so whatever the
|
||||
// interleaving nothing may escape the destination.
|
||||
func TestSymlinkEscapeConcurrent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
evil := t.TempDir()
|
||||
|
||||
r := fstest.NewRun(t)
|
||||
f := r.Flocal.(*Fs)
|
||||
linksMode(f)
|
||||
|
||||
const pairs = 50
|
||||
var wg sync.WaitGroup
|
||||
for i := range pairs {
|
||||
name := fmt.Sprintf("pwn%d", i)
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); _ = putLink(ctx, f, name, evil) }()
|
||||
go func() { defer wg.Done(); _ = putFile(ctx, f, name+"/authkeys", "PWNED") }()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
entries, err := os.ReadDir(evil)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, entries, "files escaped the destination into %q", evil)
|
||||
}
|
||||
|
||||
// TestSymlinkInTreeWriteThroughWorks checks the fix doesn't break legitimate
|
||||
// use: an in-tree symlink to a sibling directory can still be created and
|
||||
// written through, since that write stays inside the destination.
|
||||
func TestSymlinkInTreeWriteThroughWorks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
r := fstest.NewRun(t)
|
||||
f := r.Flocal.(*Fs)
|
||||
linksMode(f)
|
||||
|
||||
require.NoError(t, putFile(ctx, f, "sub/keep.txt", "hello"))
|
||||
require.NoError(t, putLink(ctx, f, "link", "sub"))
|
||||
|
||||
require.NoError(t, putFile(ctx, f, "link/file.txt", "world"))
|
||||
|
||||
// The write landed in the real sibling directory, inside the destination.
|
||||
got, err := os.ReadFile(filepath.Join(f.root, "sub", "file.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "world", string(got))
|
||||
}
|
||||
|
||||
func TestHashWithTypeNone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r := fstest.NewRun(t)
|
||||
|
||||
Reference in New Issue
Block a user