github.com/rclone/rclone: Incomplete fix of CVE-2026-71313: `translateLink` trims `.rclonelink` off the path *after* the containment check, so a source directory named `...rclonelink` makes rclone chown/utime the parent of the destination
What fixes it
- 1.75.2github.com/rclone/rclone, goGitHub
go get github.com/rclone/rclone@v1.75.2
A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.
Description
Summary
The fix for GHSA-7p4m-qxvv-g567 centralised confinement in Fs.localPath() and documents the invariant *"localPath returns the OS path for the object called name, which is always underneath f.root"*. newObject() calls it and then mutates the validated result:
localPath, err := f.localPath(remote) // validated: underneath f.root
if err != nil { return nil, err }
if f.opt.TranslateSymlinks {
// Possibly receive a new name for localPath
localPath, translatedLink = translateLink(remote, localPath) // <-- mutates it
}func translateLink(remote, localPath string) (newLocalPath string, isTranslatedLink bool) {
isTranslatedLink = strings.HasSuffix(remote, fs.LinkSuffix)
newLocalPath = strings.TrimSuffix(localPath, fs.LinkSuffix) // raw suffix trim on the whole OS path
return newLocalPath, isTranslatedLink
}fs.LinkSuffix is ".rclonelink". A name of ...rclonelink passes localPath() cleanly (filepath.Rel yields ...rclonelink, no .. prefix), and is then trimmed to the two leading dots, so Object.path becomes <root>/.. — the parent of the very root the fix exists to protect. This is validate-then-mutate: the guard is correct and is then undone.
Affected versions
v1.75.0 — which is the advisory's own first_patched_version — and master HEAD 4479a3b (v1.75.0-170, 2026-08-30). git diff between them touches only List()'s context check and Update()'s hash writer; translateLink and newObject are byte-identical.
Measured (as uid 0, go1.26.5)
Layout, with deliberately different parents so the source-side and destination-side escapes are distinguishable:
<A>/srcroot/'...rclonelink'/f.txt attacker-created DIRECTORY inside the copied tree <B>/dstroot the destination the operator chose
BEFORE <A> mtime=2001-02-03 04:05:06 owner=rcvictim mode=751 BEFORE <B> mtime=2026-09-01 08:51:23 owner=root mode=755 $ rclone sync <A>/srcroot <B>/dstroot -l --metadata ERROR : ...rclonelink: Failed to read link size: readlink <A>/srcroot/..: invalid argument ERROR : ...rclonelink: Failed to read link size: readlink <B>/dstroot/..: invalid argument INFO : ...rclonelink: Updated directory metadata AFTER <B> mtime=2001-02-03 04:05:06 owner=rcvictim mode=755 AFTER <B>/dstroot mtime=2026-09-01 08:51:23 owner=root
The parent of the destination root — a directory the operator never named — had its ownership changed to a user the attacker controls and its timestamps rewritten to attacker-chosen values. mode is unchanged only because lchmod is unsupported on Linux (Unable to set mode ... on a symlink on this OS); on platforms where haveLChmod is true it would be applied too. The readlink <B>/dstroot/.. error strings are rclone printing the escaped path itself.
MkdirMetadata (local.go:910) shows the split plainly: it calls f.localPath(dir) (guarded, creates the real directory inside the root), then f.newDirectory(dir, fi) (unguarded trim) and SetMetadata on the escaped path. The metadata sinks take o.path raw — metadata.go:101 setTimes → lChtimes, metadata.go:129 os.Lchown, xattr.go:108 xattr.LSet.
Negative controls I ran
- Same layout with
-lbut without--metadata: the destination's parent is
unchanged (mtime and owner both). --metadata is required — the report does not claim otherwise.
...rclonelinkas a file rather than a directory: the sync aborts with
failed to open source object: readlink <E>/srcroot/.. and nothing is written outside the destination. Content write remains blocked by the os.Root hardening (GHSA-cf44-9pgv-m4xc); this is a metadata-only escape.
- Sanity check that the sink is live:
os.utime('<B>/dstroot/..', follow_symlinks=False)
does change <B>.
Preconditions
- A local destination with
-l/--links/--local-linksand--metadata.
Both are documented, ordinary flags (docs/content/flags.md, docs/content/local.md).
- The default local encoding — unlike the parent advisory, no custom or
Rawencoding
is needed.
- The attacker can create a directory named
...rclonelinkat the top of the tree being
copied: a tenant on a shared upload or web root, another local user under /home or /srv, an untrusted archive/USB/network mount, or a malicious remote.
Impact
When rclone runs as root — the ordinary shape for a backup or systemd unit, and the exact shape of rclone's own GHSA-hrxh-9w67-g4cv proof of concept (rclone copy /tmp/home /tmp/home_new --links --metadata as root) — an attacker who can plant one directory name in the source tree obtains ownership of the parent of the backup destination. Owning that directory means rename/replace control over the destination tree itself, which is a privilege-escalation primitive, and rclone reports success.
Without root, the reachable effect is rewriting timestamps and extended attributes of a directory outside the chosen destination.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:N/I:H/A:N = 7.4 (High), on the root-run case. A conservative reading that charges the flag combination to attack complexity (AC:H) gives ≈6.8, in line with the parent CVE-2026-71313 (6.9). CWE-22 / CWE-59, with the specific shape being validate-then-mutate.
Suggested fix
Do the trim on the remote name, before confinement, not on the OS path afterwards:
func (f *Fs) newObject(remote string) (*Object, error) {
translatedLink := strings.HasSuffix(remote, fs.LinkSuffix)
name := remote
if f.opt.TranslateSymlinks && translatedLink {
name = strings.TrimSuffix(remote, fs.LinkSuffix)
}
localPath, err := f.localPath(name) // validate the FINAL path
if err != nil { return nil, err }
...Equivalently, re-run localPath's filepath.Rel check on the trimmed result. A trailing .rclonelink should also only be honoured when it is a suffix of the final path *component*, so that ...rclonelink cannot collapse into ... The same applies to newDirectory.
Dedup
The parent advisory is GHSA-7p4m-qxvv-g567 (CVE-2026-71313, "rclone: Local Encoding Path Traversal"), fixed in v1.75.0 by commit 6a69713. git merge-base --is-ancestor 6a697138 v1.75.0 confirms the fix is present in the version measured above, and git log 6a697138..HEAD -- backend/local vfs/vfscache lib/encoder shows no commit touching localPath or translateLink — so this is a residual of that fix, not a revert. The sibling hardening GHSA-cf44-9pgv-m4xc (os.Root for content writes) is what keeps this to metadata only, and is re-tested above as a control.
Honest limits
- Metadata-only. I confirmed content write is still contained by
os.Root. - Escape depth is exactly one directory level (
<root>/..), becauseTrimSuffixremoves a
fixed 11-character suffix once.
- Measured on Linux as root; the
modesink is unreachable there and the ownership sink is
root-only. A non-root run still rewrites timestamps and xattrs outside the destination.
---
Appendix — EVIDENCE_rclone_linksuffix_escape_20260901.txt (inlined; advisories accept no attachments)
=== rclone v1.75.0 (tag 9ee9d0a, the first_patched_version of GHSA-7p4m-qxvv-g567) — measured 2026-09-01, go1.26.5, running as uid 0 ===
layout: <A>/srcroot/'...rclonelink'/f.txt (attacker-created DIRECTORY in the copied tree)
<B>/dstroot (the destination the operator chose)
<A> and <B> are DIFFERENT parents, so the source-side and destination-side escapes are distinguishable
BEFORE <A> (source root's parent) mtime=2001-02-03 04:05:06 owner=rcvictim mode=751
BEFORE <B> (destination root's parent) mtime=2026-09-01 08:51:23 owner=root mode=755
$ rclone sync <A>/srcroot <B>/dstroot -l --metadata
ERROR : ...rclonelink: Failed to read link size: readlink <A>/srcroot/..: invalid argument
ERROR : ...rclonelink: Failed to read link size: readlink <B>/dstroot/..: invalid argument <-- o.path escaped the destination root
INFO : ...rclonelink: Updated directory metadata
AFTER <B> mtime=2001-02-03 04:05:06 owner=rcvictim mode=755
AFTER <B>/dstroot mtime=2026-09-01 08:51:23 owner=root
=> os.Lchown + lutimes were applied to the PARENT of the destination root.
=> mode is unchanged because lchmod is unsupported on Linux ('Unable to set mode ... on a symlink on this OS').
NEGATIVE CONTROLS I ran:
* same layout, '-l' WITHOUT '--metadata' -> <D> unchanged (mtime and owner both untouched). --metadata is required.
* '...rclonelink' as a FILE instead of a directory -> 'failed to open source object: readlink <E>/srcroot/..' and the
sync aborts; nothing is written outside the destination (the os.Root hardening for content writes holds).
* sanity: python os.utime('<B>/dstroot/..', follow_symlinks=False) does change <B>, so the sink itself is live.
VERSION: identical code at master HEAD 4479a3b (v1.75.0-170); 'git diff HEAD -- backend/local/local.go' touches only
List()'s ctx check and Update()'s hash writer — translateLink/newObject are byte-identical.Text from GitHub Advisory Database
Affected versions 1
- github.com/rclone/rclone, go<= 1.75.1GitHub