From 215e6dbc6517f2f48285545f98e7367777c8bc8a Mon Sep 17 00:00:00 2001 From: Nick Craig-Wood Date: Wed, 12 Aug 2026 11:16:57 +0100 Subject: [PATCH] local: fix btime escaping the root via a planted symlink GHSA-f8g7-2xjc-7mfh CVE-PENDING The birth-time (btime) write in writeMetadataToFile followed symlinks for any object that was not a translated link, so under -l/--links a symlink planted by an untrusted source at the destination path could redirect the btime write to a target outside the backup destination on OSes where birth time is settable (Windows). Use the NOFOLLOW birth-time write whenever translating symlinks, not only for translated links. It is a no-op on a real file or directory and stops a planted symlink from being followed out of the destination. --- backend/local/local_internal_test.go | 36 ++++++++++++++++++++++++++++ backend/local/metadata.go | 5 +++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/backend/local/local_internal_test.go b/backend/local/local_internal_test.go index 20e244df9..82612be07 100644 --- a/backend/local/local_internal_test.go +++ b/backend/local/local_internal_test.go @@ -447,6 +447,42 @@ func TestDirMetadataInTreeWorks(t *testing.T) { require.True(t, fi.ModTime().Equal(fstest.Time("2001-02-03T04:05:06Z"))) } +// TestDirBTimeThroughPlantedSymlinkBlocked checks a btime write +// through a symlink can't escape the root. +func TestDirBTimeThroughPlantedSymlinkBlocked(t *testing.T) { + if !haveSetBTime { + t.Skip("birth time is not settable on this OS") + } + ctx := context.Background() + + evil := t.TempDir() + evilDir := filepath.Join(evil, "secret.d") + require.NoError(t, os.Mkdir(evilDir, 0700)) + + // Read the outside dir's btime through a local Fs rooted at evil. + evilFsRaw, err := NewFs(ctx, "local", evil, configmap.Simple{}) + require.NoError(t, err) + evilFs := evilFsRaw.(*Fs) + readBTime := func() string { + o, err := evilFs.newObject("secret.d") + require.NoError(t, err) + require.NoError(t, o.lstat()) + m, err := o.Metadata(ctx) + require.NoError(t, err) + return m["btime"] + } + before := readBTime() + + r := fstest.NewRun(t) + f := r.Flocal.(*Fs) + linksMode(f) + require.NoError(t, putLink(ctx, f, "pwn", evilDir)) + + _, _ = f.MkdirMetadata(ctx, "pwn", fs.Metadata{"btime": "2001-02-03T04:05:06Z"}) + + require.Equal(t, before, readBTime(), "btime escaped through planted symlink to %q", evilDir) +} + // 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) { diff --git a/backend/local/metadata.go b/backend/local/metadata.go index 31d298767..bdbcad1f9 100644 --- a/backend/local/metadata.go +++ b/backend/local/metadata.go @@ -105,7 +105,10 @@ func (o *Object) writeMetadataToFile(m fs.Metadata) (outErr error) { } if haveSetBTime { if btimeOK { - if o.translatedLink { + // When translating symlinks, never follow the path. A planted symlink must + // not redirect the birth-time write out of the root. The NOFOLLOW open is a + // no-op on a real file or directory + if o.translatedLink || o.fs.opt.TranslateSymlinks { err = lsetBTime(o.path, btime) } else { err = setBTime(o.path, btime)