{"schema":"threatline.record/2","record":{"id":"GHSA-55qp-jrwr-x827","aliases":["GHSA-55qp-jrwr-x827"],"summary":"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","details":"## Summary\r\n\r\nThe fix for GHSA-7p4m-qxvv-g567 centralised confinement in `Fs.localPath()` and documents\r\nthe invariant *\"localPath returns the OS path for the object called name, which is **always\r\nunderneath f.root**\"*. `newObject()` calls it and then **mutates the validated result**:\r\n\r\n```go\r\nlocalPath, err := f.localPath(remote)      // validated: underneath f.root\r\nif err != nil { return nil, err }\r\n\r\nif f.opt.TranslateSymlinks {\r\n\t// Possibly receive a new name for localPath\r\n\tlocalPath, translatedLink = translateLink(remote, localPath)   // <-- mutates it\r\n}\r\n```\r\n\r\n```go\r\nfunc translateLink(remote, localPath string) (newLocalPath string, isTranslatedLink bool) {\r\n\tisTranslatedLink = strings.HasSuffix(remote, fs.LinkSuffix)\r\n\tnewLocalPath = strings.TrimSuffix(localPath, fs.LinkSuffix)   // raw suffix trim on the whole OS path\r\n\treturn newLocalPath, isTranslatedLink\r\n}\r\n```\r\n\r\n`fs.LinkSuffix` is `\".rclonelink\"`. A name of `...rclonelink` passes `localPath()` cleanly\r\n(`filepath.Rel` yields `...rclonelink`, no `..` prefix), and is then trimmed to the two\r\nleading dots, so `Object.path` becomes `<root>/..` — the parent of the very root the fix\r\nexists to protect. This is validate-then-mutate: the guard is correct and is then undone.\r\n\r\n## Affected versions\r\n\r\n`v1.75.0` — which is the advisory's own `first_patched_version` — and master\r\n`HEAD 4479a3b` (v1.75.0-170, 2026-08-30). `git diff` between them touches only `List()`'s\r\ncontext check and `Update()`'s hash writer; `translateLink` and `newObject` are\r\nbyte-identical.\r\n\r\n## Measured (as uid 0, go1.26.5)\r\n\r\nLayout, with deliberately **different** parents so the source-side and destination-side\r\nescapes are distinguishable:\r\n\r\n```\r\n<A>/srcroot/'...rclonelink'/f.txt     attacker-created DIRECTORY inside the copied tree\r\n<B>/dstroot                           the destination the operator chose\r\n```\r\n\r\n```\r\nBEFORE  <A>  mtime=2001-02-03 04:05:06  owner=rcvictim  mode=751\r\nBEFORE  <B>  mtime=2026-09-01 08:51:23  owner=root      mode=755\r\n\r\n$ rclone sync <A>/srcroot <B>/dstroot -l --metadata\r\nERROR : ...rclonelink: Failed to read link size: readlink <A>/srcroot/..: invalid argument\r\nERROR : ...rclonelink: Failed to read link size: readlink <B>/dstroot/..: invalid argument\r\nINFO  : ...rclonelink: Updated directory metadata\r\n\r\nAFTER   <B>          mtime=2001-02-03 04:05:06  owner=rcvictim  mode=755\r\nAFTER   <B>/dstroot  mtime=2026-09-01 08:51:23  owner=root\r\n```\r\n\r\nThe **parent of the destination root** — a directory the operator never named — had its\r\nownership changed to a user the attacker controls and its timestamps rewritten to\r\nattacker-chosen values. `mode` is unchanged only because lchmod is unsupported on Linux\r\n(`Unable to set mode ... on a symlink on this OS`); on platforms where `haveLChmod` is true\r\nit would be applied too. The `readlink <B>/dstroot/..` error strings are rclone printing the\r\nescaped path itself.\r\n\r\n`MkdirMetadata` (`local.go:910`) shows the split plainly: it calls `f.localPath(dir)`\r\n(guarded, creates the real directory inside the root), then `f.newDirectory(dir, fi)`\r\n(unguarded trim) and `SetMetadata` on the escaped path. The metadata sinks take `o.path`\r\nraw — `metadata.go:101` `setTimes` → `lChtimes`, `metadata.go:129` `os.Lchown`,\r\n`xattr.go:108` `xattr.LSet`.\r\n\r\n## Negative controls I ran\r\n\r\n* Same layout with `-l` but **without** `--metadata`: the destination's parent is\r\n  **unchanged** (mtime and owner both). `--metadata` is required — the report does not claim\r\n  otherwise.\r\n* `...rclonelink` as a **file** rather than a directory: the sync aborts with\r\n  `failed to open source object: readlink <E>/srcroot/..` and nothing is written outside the\r\n  destination. **Content write remains blocked** by the `os.Root` hardening\r\n  (GHSA-cf44-9pgv-m4xc); this is a metadata-only escape.\r\n* Sanity check that the sink is live: `os.utime('<B>/dstroot/..', follow_symlinks=False)`\r\n  does change `<B>`.\r\n\r\n## Preconditions\r\n\r\n* A **local destination** with `-l` / `--links` / `--local-links` **and** `--metadata`.\r\n  Both are documented, ordinary flags (`docs/content/flags.md`, `docs/content/local.md`).\r\n* The **default** local encoding — unlike the parent advisory, no custom or `Raw` encoding\r\n  is needed.\r\n* The attacker can create a directory named `...rclonelink` at the top of the tree being\r\n  copied: a tenant on a shared upload or web root, another local user under `/home` or\r\n  `/srv`, an untrusted archive/USB/network mount, or a malicious remote.\r\n\r\n## Impact\r\n\r\nWhen rclone runs as **root** — the ordinary shape for a backup or systemd unit, and the\r\nexact shape of rclone's own GHSA-hrxh-9w67-g4cv proof of concept\r\n(`rclone copy /tmp/home /tmp/home_new --links --metadata` as root) — an attacker who can\r\nplant one directory name in the source tree obtains **ownership of the parent of the backup\r\ndestination**. Owning that directory means rename/replace control over the destination tree\r\nitself, which is a privilege-escalation primitive, and rclone reports success.\r\n\r\nWithout root, the reachable effect is rewriting timestamps and extended attributes of a\r\ndirectory outside the chosen destination.\r\n\r\n## Severity\r\n\r\n`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.\r\nA conservative reading that charges the flag combination to attack complexity (`AC:H`)\r\ngives ≈6.8, in line with the parent CVE-2026-71313 (6.9). CWE-22 / CWE-59, with the\r\nspecific shape being validate-then-mutate.\r\n\r\n## Suggested fix\r\n\r\nDo the trim on the **remote name**, before confinement, not on the OS path afterwards:\r\n\r\n```go\r\nfunc (f *Fs) newObject(remote string) (*Object, error) {\r\n\ttranslatedLink := strings.HasSuffix(remote, fs.LinkSuffix)\r\n\tname := remote\r\n\tif f.opt.TranslateSymlinks && translatedLink {\r\n\t\tname = strings.TrimSuffix(remote, fs.LinkSuffix)\r\n\t}\r\n\tlocalPath, err := f.localPath(name)      // validate the FINAL path\r\n\tif err != nil { return nil, err }\r\n\t...\r\n```\r\n\r\nEquivalently, re-run `localPath`'s `filepath.Rel` check on the trimmed result. A trailing\r\n`.rclonelink` should also only be honoured when it is a suffix of the final path\r\n*component*, so that `...rclonelink` cannot collapse into `..`. The same applies to\r\n`newDirectory`.\r\n\r\n## Dedup\r\n\r\nThe parent advisory is GHSA-7p4m-qxvv-g567 (CVE-2026-71313, \"rclone: Local Encoding Path\r\nTraversal\"), fixed in v1.75.0 by commit `6a69713`. `git merge-base --is-ancestor 6a697138\r\nv1.75.0` confirms the fix is present in the version measured above, and\r\n`git log 6a697138..HEAD -- backend/local vfs/vfscache lib/encoder` shows no commit touching\r\n`localPath` or `translateLink` — so this is a residual of that fix, not a revert. The\r\nsibling hardening GHSA-cf44-9pgv-m4xc (`os.Root` for content writes) is what keeps this to\r\nmetadata only, and is re-tested above as a control.\r\n\r\n## Honest limits\r\n\r\n* Metadata-only. I confirmed content write is still contained by `os.Root`.\r\n* Escape depth is exactly one directory level (`<root>/..`), because `TrimSuffix` removes a\r\n  fixed 11-character suffix once.\r\n* Measured on Linux as root; the `mode` sink is unreachable there and the ownership sink is\r\n  root-only. A non-root run still rewrites timestamps and xattrs outside the destination.\r\n\r\n\r\n---\r\n\r\n## Appendix — `EVIDENCE_rclone_linksuffix_escape_20260901.txt` (inlined; advisories accept no attachments)\r\n\r\n```text\r\n=== 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 ===\r\n\r\nlayout: <A>/srcroot/'...rclonelink'/f.txt        (attacker-created DIRECTORY in the copied tree)\r\n        <B>/dstroot                              (the destination the operator chose)\r\n        <A> and <B> are DIFFERENT parents, so the source-side and destination-side escapes are distinguishable\r\n\r\nBEFORE   <A> (source root's parent)      mtime=2001-02-03 04:05:06  owner=rcvictim  mode=751\r\nBEFORE   <B> (destination root's parent) mtime=2026-09-01 08:51:23  owner=root      mode=755\r\n\r\n$ rclone sync <A>/srcroot <B>/dstroot -l --metadata\r\n  ERROR : ...rclonelink: Failed to read link size: readlink <A>/srcroot/..: invalid argument\r\n  ERROR : ...rclonelink: Failed to read link size: readlink <B>/dstroot/..: invalid argument   <-- o.path escaped the destination root\r\n  INFO  : ...rclonelink: Updated directory metadata\r\n\r\nAFTER    <B>                             mtime=2001-02-03 04:05:06  owner=rcvictim  mode=755\r\nAFTER    <B>/dstroot                     mtime=2026-09-01 08:51:23  owner=root\r\n  => os.Lchown + lutimes were applied to the PARENT of the destination root.\r\n  => mode is unchanged because lchmod is unsupported on Linux ('Unable to set mode ... on a symlink on this OS').\r\n\r\nNEGATIVE CONTROLS I ran:\r\n  * same layout, '-l' WITHOUT '--metadata'  -> <D> unchanged (mtime and owner both untouched). --metadata is required.\r\n  * '...rclonelink' as a FILE instead of a directory -> 'failed to open source object: readlink <E>/srcroot/..' and the\r\n    sync aborts; nothing is written outside the destination (the os.Root hardening for content writes holds).\r\n  * sanity: python os.utime('<B>/dstroot/..', follow_symlinks=False) does change <B>, so the sink itself is live.\r\n\r\nVERSION: identical code at master HEAD 4479a3b (v1.75.0-170); 'git diff HEAD -- backend/local/local.go' touches only\r\n         List()'s ctx check and Update()'s hash writer — translateLink/newObject are byte-identical.\r\n```\r\n","categories":["Unclassified"],"product":"github.com/rclone/rclone","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/rclone/rclone/security/advisories/GHSA-55qp-jrwr-x827","source_modified_at":"2026-10-09T10:55:13Z","fetched_at":"2026-10-26T00:08:39.284423Z","package":{"ecosystem":"go","name":"github.com/rclone/rclone"},"range":"<= 1.75.1"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/rclone/rclone/security/advisories/GHSA-55qp-jrwr-x827","source_modified_at":"2026-10-09T10:55:13Z","fetched_at":"2026-10-26T00:08:39.284423Z","package":{"ecosystem":"go","name":"github.com/rclone/rclone"},"version":"1.75.2"}],"cvss":6.1,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-09T10:55:13Z","modified_at":"2026-10-09T10:55:13Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/rclone/rclone/security/advisories/GHSA-55qp-jrwr-x827","fetched_at":"2026-10-26T00:08:39.284423Z","source_modified_at":"2026-10-09T10:55:13Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-09T10:55:13Z","content_hash":"6211b799533f2ee1752267f29358024d66fd790bef6ee17b3d3eb055c5850494"}}
