rclone: source object names can escape the configured root on upload
Multiple backends, when given a specially crafted object to copy, can escape the backend confinement.
| Backend | Keep/Close | Per-backend severity |
|---|---|---|
| sftp | Medium | Real filesystem escape, fires under default encoding. |
| smb | Low-Medium | Escapes to a different SMB share the credential can reach. |
| ftp | Low | Real, leading-.. overshoot PoC is partly neutralized by encoding; escape bounded to at/below the login base. |
| webdav | Low | Server-side ACLs are the real boundary. |
| b2 | Low | Same-account sibling bucket crossing on a flat keyspace. |
| swift | Low | Same, container. |
| qingstor | Low | Same. |
| oracleobjectstorage | Low | Same. |
| internetarchive | Low | IA items are owner-writable only; confined to user's own items. |
| storj | Low | Can retarget a different bucket in the same access grant. |
| filelu | Low | Confined to the user's own account. |
| shade | Low | Confined to the user's own drive. |
| sia | Low | siad API password already grants full-daemon access. |
rclone core does not sanitize .. in a source object's Remote() - verified: nothing in fs/march, fs/sync, fs/list, or fs/operations rejects .. segments before the name reaches the destination backend's Put/Update/Mkdir. Confinement is therefore each backend's responsibility, and these backends join root + remote without a check.
This divides into two classes:
Bucket based backends - bucket.Split(path.Join(f.root, rootRelativePath)):
backend/b2/b2.go:404, backend/swift/swift.go:464, backend/qingstor/qingstor.go:198, backend/oracleobjectstorage/oracleobjectstorage.go:245, backend/internetarchive/internetarchive.go:1016, backend/smb/smb.go:885, backend/storj/fs.go:289.path.Join collapses .. on the standard (ASCII) form before encoding is applied (e.g. FromStandardPath(path.Join(...)) at backend/b2/b2.go:1641), so EncodeDot never gets the chance to neutralize the ...lib/bucket.Join does not clean paths (keeps .. as a literal key segment); path.Join does. backend/s3, backend/azureblob, backend/googlecloudstorage already use bucket.Join and are therefore not affected.Path based backends - path.Join(root, remote) onto a real path:
remotePath = path.Join(f.absRoot, f.opt.Enc.FromStandardPath(remote)) (backend/sftp/sftp.go:2497). Default encoding is encoder.Display (== Standard), and FromStandardPath short-circuits to a pass-through in that mode, so .. survives; f.absRoot is absolute, so path.Join("/home/user/root", "../../../../etc/passwd") -> /etc/passwd.filePath at backend/webdav/webdav.go:426-432.path.Join(f.root, remote) at ~14 sites (e.g. backend/ftp/ftp.go:1247).For any of these to fire, a source must hand rclone a Remote() containing raw ... That is only possible when:
../../x as one directory entry), andEncodeDot and rewrites a .. segment to fullwidth .., so you cannot create such a key through rclone.rclone's source-side listing does pass a natively-planted raw .. key through unchanged (verified for b2: remote := file.Name[len(prefix):] after ToStandardPath, backend/b2/b2.go:858,867). The reports never establish this precondition; it is the same omission across every member of the class.
# Step 1 - attacker, using NATIVE S3 tooling (NOT rclone) on a source the victim ingests from:
aws s3api put-object --bucket shared-drop --key '../../victim-backups/pwned.txt' --body evil.txt
# Step 2 - victim's ordinary ingest:
rclone copy s3-drop:shared-drop b2:victim-uploads/incoming
# path.Join("victim-uploads/incoming", "../../victim-backups/pwned.txt") = "victim-backups/pwned.txt"
# -> lands in the victim's victim-backups bucket instead of under incoming/
The blast radius is the victim's own account (a bucket/share/path the configured credential already reaches) - integrity misdirection, not a cross-tenant or confidentiality breach. sftp/smb are the exception in reach (server filesystem / other share), still bounded by the login's own permissions.
This is the same class as the already-fixed local backend advisory https://github.com/rclone/rclone/security/advisories/GHSA-7p4m-qxvv-g567, which added (*Fs).localPath returning errPathEscapes for names resolving outside the root (backend/local/local.go:819-826). That fix was justified because the destination was the operator's own OS filesystem; the same reasoning extends (at lower severity) to sftp/smb.
Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.