All vulnerabilities
GHSA-35g6-9fjv-rmx5

github.com/rclone/rclone: serve s3 --auth-proxy: multipart upload operations are not scoped to the authenticated tenant

github.com/rclone/rclone
CVSS
6.4Medium
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed inNone listedNo source names a fixed version yet
Published9 Oct 2026Updated 9 Oct 2026GitHub

What fixes it

No source in this snapshot names a fixed version.

Description

Summary

rclone serve s3 --auth-proxy maps several access key IDs to different backends, one per tenant. Ordinary object operations are isolated, each resolving a per-request VFS from the authenticated context. The multipart operations are not: UploadPart, CompleteMultipartUpload and AbortMultipartUpload find the upload by upload ID alone and act on the VFS captured at create time, never re-checking the caller. A tenant who learns another tenant's in-flight (bucket, object, uploadID) can inject bytes into, finalise, or abort that upload against the victim's backend.

Details

One s3Backend, shared by all tenants, holds two process-global maps with no tenant dimension, multipartUploads and meta (cmd/serve/s3/backend.go:42,46). Ordinary handlers call b.s.getVFS(ctx); the multipart sub-operations resolve the upload by ID only:

func (b *s3Backend) loadUpload(uploadID gofakes3.UploadID) (*multipartUpload, error) {
	v, ok := b.multipartUploads.Load(uploadID)
	if !ok {
		return nil, gofakes3.ErrNoSuchUpload
	}
	return v.(*multipartUpload), nil
}

UploadPart, CompleteMultipartUpload and AbortMultipartUpload all start with b.loadUpload(uploadID) and never call getVFS. CreateMultipartUpload captures the initiator's VFS in up.vfs (multipart.go:184) under a random UUIDv4 upload id; completion writes through it (up.vfs.Rename(up.streamFp, up.fp), multipart.go:413), so the object lands in the initiator's backend at the key the initiator chose. That key is fixed by the victim at initiate, so this is not an arbitrary cross-tenant write, and no other tenant's at-rest content is read.

The only remaining check is vendored gofakes3 v0.0.9, whose registry is keyed by bucket name, with no tenant dimension:

func (u *uploader) getUnlocked(bucket, object string, id UploadID) (mu *multipartUpload, err error) {
	bucketUps, ok := u.buckets[bucket]

then bucketUps.uploads[id], with a final mu.Bucket != bucket || mu.Object != object check (uploader.go:417,422). So the exact (bucket, object, uploadID) is the whole authorization, honoured whichever tenant presents it. Discovery is asymmetric: ListMultipartUploads and ListParts call ensureBucketExists against the caller's own VFS, so listing a victim's uploads needs the attacker to own a same-named bucket; the inject, complete and abort handlers skip that check and need no bucket once the tuple is known.

PoC

Two-tenant proxy (key A to root A, key B to root B), one loopback server, raw SigV4. Positive control: A puts bkt/secret.txt; B makes its own bkt but GET returns 404 and on disk the object is only under A's root, so the keys are separate backends. Cross-tenant, all observed: B lists a same-named bkt and sees A's upload id and key; B, owning no bucket, uploads a part that ends up in A's completed object, and separately completes A's upload into A's root; B aborts A's upload, after which A's UploadPart returns NoSuchUpload; a wrong bucket or object name is refused. The shared meta map similarly leaks custom X-Amz-Meta-* cross-tenant on HeadObject.

Preconditions

One server, --auth-proxy, several keys to different backends; the attacker is an authenticated tenant. Inject, complete and abort need the victim's (bucket, object, uploadID); the id is a random UUIDv4, so it must be obtained, demonstrated here via ListMultipartUploads while owning a same-named bucket. The victim upload must be in flight; the reaper aborts idle uploads only after --multipart-expiry (default 24h).

Affected operations

Cross the boundary: UploadPart, CompleteMultipartUpload, AbortMultipartUpload (observed), plus ListMultipartUploads and ListParts (metadata). Unaffected: ordinary object operations, which resolve the VFS per request. Untested inference: with --disable-multipart-streaming and no VFS cache, gofakes3 uses its in-memory uploader and completion calls PutObject with the completing request's context, so a cross-tenant complete would write into the completer's own store; the upload-ID gate still permits cross-tenant abort. I did not run this.

Impact

Integrity and availability of in-flight multipart uploads across tenants meant to be isolated, plus disclosure of upload and custom object metadata. Not a breach of at-rest content, and the destination key is not attacker-chosen. Per-key isolation is intended: TestAuthKeyPerServer and TestAuthProxyKeysNotRegistered in cmd/serve/s3/s3_test.go assert separate keys and proxy secrets do not cross tenants. I am not proposing a CVSS vector and leave scoring to the maintainer.

Suggested fix

Scope each multipart sub-operation to the caller: bind the upload to the caller's VFS at create time and reject UploadPart, CompleteMultipartUpload, AbortMultipartUpload, ListParts and ListMultipartUploads entries belonging to another tenant, for example by comparing the request-context VFS against up.vfs.

Version

Reproduced on v1.75.1. cmd/serve/s3/multipart.go and cmd/serve/s3/backend.go are byte-identical on master at commit 1c3e432c3fdbf98c5800c0390496d6944bac53b0, and master pins the same gofakes3 v0.0.9

Text from GitHub Advisory Database

Affected versions 1

  • github.com/rclone/rclone, go>=v1.68.0 <= 1.75.1GitHub