github.com/rclone/rclone: rclone stable selfupdate accepts an archive hash from an unsigned clearsign prefix
What fixes it
- 1.75.2github.com/rclone/rclone, goGitHub
go get github.com/rclone/rclone@v1.75.2
A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.
Description
Executive Summary
Mallory controls the bytes returned by rclone's stable release distribution path, for example after compromise of the release origin, CDN, or TLS termination, or through interception with a certificate trusted by the client. When a user runs stable rclone selfupdate without --check, Mallory can prepend an unsigned checksum line to a genuine PGP-clearsigned SHA256SUMS response and serve an archive matching that unsigned checksum. rclone validates the genuine signed block but then searches the original, partly unsigned response for the archive hash. The unsigned line therefore authorizes Mallory's archive.
This is not a claim that an ordinary on-path attacker can bypass HTTPS. The attacker must already be able to control authenticated responses from the release distribution path. The defect matters because rclone's embedded signing key is explicitly intended to protect an update after release infrastructure or the delivery path has been compromised.
The vulnerable verifier was introduced with selfupdate in rclone v1.55.0 and remains present in v1.75.1 and the current master source reviewed for this report. No fixed release was available at the time of testing. I reviewed the introducing history and the v1.55.0 and v1.75.1 release sources, reproduced the direct verifier bypass, drove the real v1.75.1 download, verification, and ZIP extraction path against a local HTTP test server, and verified that resolving the hash only from authenticated bytes rejects the attack while preserving the genuine signed flow.
The local end-to-end test demonstrates that an attacker ZIP passes stable-channel verification and its 26-byte payload is extracted as the proposed replacement executable. Source review shows that the ordinary installation path subsequently replaces the target executable, while the package path passes a verified .deb or .rpm file to the system package manager. The safe reproduction does not replace a live binary and does not invoke a package manager.
Background
rclone publishes release archives and a clearsigned SHA256SUMS file. Its release-signing documentation says that selfupdate checks the SHA-256 hash and PGP signature so a release that has been tampered with is not installed. Stable selfupdate is the default; beta updates follow a separate path that does not use this checksum verification.
For the default ZIP flow, downloadUpdate obtains the archive, calculates its SHA-256 digest, downloads SHA256SUMS, calls verifyHashsum, and extracts the expected rclone executable to a temporary file. InstallUpdate then calls replaceExecutable. For .deb and .rpm, downloadUpdate writes the verified package to a temporary file and installPackage invokes dpkg -i or rpm -i.
The tested topology uses the public rclone v1.75.1 source and its genuine signed test fixture. A local httptest server plays Mallory's compromised distribution endpoint. It serves an attacker ZIP and a SHA256SUMS body composed of one unsigned checksum line followed by the unmodified genuine signed block. No external service is tested, and the reproduction stops after extracting the attacker-controlled bytes to a test-owned temporary file.
Vulnerability Details
The failure is a mismatch between the bytes that rclone authenticates and the bytes it later trusts. In v1.75.1, verifyHashsumDownloaded decodes the clearsigned block and validates the signature over block.Bytes, but passes the original sumsBuf to findFileHash:
block, rest := clearsign.Decode(sumsBuf)
if block == nil {
return errors.New("invalid hashsum signature: couldn't find detached signature")
}
if len(rest) > 0 {
return fmt.Errorf("invalid hashsum signature: %d bytes of unsigned data", len(rest))
}
_, err = openpgp.CheckDetachedSignature(keyRing, bytes.NewReader(block.Bytes), block.ArmoredSignature.Body, nil)
if err != nil {
return fmt.Errorf("invalid hashsum signature: %w", err)
}
wantHash, err := findFileHash(sumsBuf, archive)This excerpt is from cmd/selfupdate/verify.go, function verifyHashsumDownloaded. Checking rest rejects bytes after the signed message, but it does not establish that the signed block starts at byte zero.
The pinned github.com/ProtonMail/go-crypto v1.4.1 dependency deliberately searches for the first clearsigned message and discards preceding bytes:
// Decode finds the first clearsigned message in data and returns it, as well as // the suffix of data which remains after the message. Any prefix data is // discarded.
Consequently, this response decodes to a valid signed block with an empty suffix:
<SHA-256 of Mallory's archive> rclone-v1.75.1-linux-amd64.zip -----BEGIN PGP SIGNED MESSAGE----- Hash: SHA1 <genuine signed checksum lines> -----BEGIN PGP SIGNATURE----- <genuine signature> -----END PGP SIGNATURE-----
The unsigned prefix is excluded from block.Bytes, so it does not invalidate the signature. It remains in sumsBuf, however. findFileHash in cmd/selfupdate/selfupdate.go scans from the beginning and returns the first valid checksum line for the requested filename:
func findFileHash(buf []byte, filename string) (hash []byte, err error) {
lines := bufio.NewScanner(bytes.NewReader(buf))
for lines.Scan() {
tokens := strings.Split(lines.Text(), " ")
if len(tokens) == 2 && tokens[1] == filename {
if hash, err := hex.DecodeString(tokens[0]); err == nil {
return hash, nil
}
}
}
return nil, fmt.Errorf("%s: unable to find hash", filename)
}The attacker line therefore wins before the parser reaches the authentic line. The archive digest matches the unsigned value and verification returns success.
Commit 6fa74340a0964af2704f8b874e09c541e15e4e8d introduced selfupdate and this raw-buffer lookup. rclone v1.54.1 has no self-update verifier. The v1.55.0 release verifies the PGP signature over the decoded signed block but searches the original buffer for the hash, and v1.75.1 retains the same mismatch. The current master revision reviewed for this report also retains it.
Exploitability Analysis
The narrow demonstrated primitive is stable-channel archive replacement. Given control over both responses from the release distribution path, Mallory can make the real v1.75.1 downloadUpdate function accept an attacker ZIP and extract its expected rclone entry. A separate safe test confirmed the same verification bypass for bytes on the .deb path. It did not invoke dpkg; the subsequent package-manager call is established by installPackage source, not by an executed installation.
Exploitation requires all of the following:
- Mallory controls bytes returned for the stable archive and its
SHA256SUMSfile through origin, CDN, or TLS-termination compromise, or trusted-certificate interception. - The user runs stable
rclone selfupdatewithout--check. - Mallory supplies a syntactically valid archive or package for the selected platform and filename.
Generic network interception without a trusted certificate is insufficient because the downloads use HTTPS. The attack does not require the PGP private key and does not modify the genuine signed block.
The positive end-to-end test reached the extracted attacker-controlled replacement file. Source then passes that file to replaceExecutable, so a normal successful self-update replaces the target with Mallory's executable under the invoking user's permissions. That executable runs when the updated rclone is next invoked. If a privileged user performs the update, the replacement has that privileged execution opportunity. These later effects were not triggered by the safe test.
Two controls isolate the defect. The unmodified signed fixture and genuine archive digest pass, showing that the baseline signing path is valid. Repeating verification while changing only the lookup from findFileHash(sumsBuf, archive) to findFileHash(block.Bytes, archive) rejects the prefixed attacker hash with a digest mismatch and still accepts the genuine signed hash.
The proposed conservative severity is High, CVSS 3.1 7.5 (CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H). Attack complexity is High because exploitation presupposes control of an authenticated release-distribution response rather than ordinary network position. User interaction is Required because a user must invoke self-update. Successful installation permits code execution with that user's privileges and can affect confidentiality, integrity, and availability.
Proof of Concept
The sibling poc/ directory contains:
run.sh, which downloads the public v1.75.1 source archive, checks its pinned SHA-256 digest, installs the test file into the extracted source, and runs only the three named tests;prefix_bypass_test.go, which reproduces the direct verifier bypass, drives the real local download and ZIP extraction path, and implements the authenticated-buffer negative control.
The runner requires a POSIX shell, curl, sha256sum, tar, and Go. Go's automatic toolchain selection is enabled. All build and temporary files stay in poc/.work, which the runner removes on exit. The test server listens only on a local ephemeral port. The test neither calls replaceExecutable nor invokes dpkg or rpm.
Run from this report directory:
cd poc ./run.sh
Observed output from the final v1.75.1 run:
rclone-v1.75.1.tar.gz: OK
=== RUN TestClearsignPrefixBypass
prefix_bypass_poc_test.go:89: BYPASS CONFIRMED: unsigned prefix hash 195b67079393de835efb4c7f27ba7b134016024ba695a88284c17248ea82695b accepted
prefix_bypass_poc_test.go:94: GENUINE CONTROL PASSED: signed archive hash accepted
--- PASS: TestClearsignPrefixBypass (0.00s)
=== RUN TestDownloadUpdatePrefixBypassE2E
prefix_bypass_poc_test.go:146: E2E BYPASS CONFIRMED: attacker archive verified and extracted (26 bytes)
--- PASS: TestDownloadUpdatePrefixBypassE2E (0.00s)
=== RUN TestAuthenticatedBufferLookupControl
prefix_bypass_poc_test.go:161: FIX CONTROL PASSED: authenticated-buffer lookup rejected unsigned prefix
prefix_bypass_poc_test.go:166: FIX CONTROL PASSED: genuine signed hash remained valid
--- PASS: TestAuthenticatedBufferLookupControl (0.00s)
PASS
ok github.com/rclone/rclone/cmd/selfupdate 0.029sThe first test proves that the production verifier accepts a digest supplied only in the unsigned prefix. The second proves that this acceptance reaches the real download and extraction sink. The third rules out a problem with the genuine fixture and demonstrates that restricting lookup to authenticated bytes blocks the attack without breaking the normal signed flow.
Remediation
Resolve the archive hash exclusively from the authenticated clearsigned message:
-wantHash, err := findFileHash(sumsBuf, archive) +wantHash, err := findFileHash(block.Bytes, archive)
This is the minimal source-compatible correction. As additional hardening, reject any response where the clearsigned message does not begin at byte zero, rather than relying on the dependency's permissive prefix handling. The security decision should never consume bytes outside the exact message passed to CheckDetachedSignature.
Regression coverage should include:
- an unsigned matching checksum before a genuine signed block, which must be rejected;
- unsigned bytes after the signed block, which must remain rejected;
- duplicate filenames inside the authenticated block, with an explicit deterministic policy;
- the genuine signed fixture and archive, which must continue to pass;
- the ZIP,
.deb, and.rpmdownload paths, ensuring each uses only an authenticated digest.
The authenticated-buffer control in the PoC exercised the one-line correction against both the attack and genuine flow on v1.75.1. This is a tested proposed remediation, not an upstream-shipped fix.
Summary
rclone v1.55.0 through v1.75.1, as well as the reviewed current source, verify a PGP signature over block.Bytes but authorize the downloaded archive using a checksum parsed from the larger, partly unsigned response. An attacker who controls authenticated responses from the stable release distribution path can prepend a checksum for an attacker archive, preserve a genuine signature, and make selfupdate accept and extract the replacement. Resolving the checksum only from block.Bytes closes the mismatch and preserved the genuine signed update flow in testing.
Text from GitHub Advisory Database
Affected versions 1
- github.com/rclone/rclone, go>= 1.55.0, <= 1.75.1GitHub