All vulnerabilities
GHSA-55qp-jrwr-x827

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

github.com/rclone/rclone
CVSS
6.1Medium
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed in1.75.2Fixed versionGitHub
Published9 Oct 2026Updated 9 Oct 2026GitHub

What fixes it

  • 1.75.2github.com/rclone/rclone, goGitHubgo 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 -l but without --metadata: the destination's parent is

unchanged (mtime and owner both). --metadata is required — the report does not claim otherwise.

  • ...rclonelink as 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-links and --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 Raw encoding

is needed.

  • The attacker can create a directory named ...rclonelink at 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>/..), because TrimSuffix removes a

fixed 11-character suffix once.

  • Measured on Linux as root; the mode sink 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