h3llh0und

CVE-2026-116191: an anonymous WebDAV client could write outside the folder rclone was told to serve

A common way to run rclone serve webdav is one process per user, each serving that user's own folder of a shared remote. I found that two requests let a client step out of its folder and overwrite, move and delete the other users' files, or create new ones next to them. Reading what was already there stayed refused. CVSS 7.5 High (v3.1), fixed in 1.75.2.

Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Everything below ran on my own machine: the released rclone binaries from v1.71.0 to v1.75.1, a source build of master, and two local rclone processes over a throwaway directory tree, with no real remote and no real credentials. The issue was reported privately through the project’s security advisory process. It is written up here after the advisory, the fixed release and the CVE identifier were public.

rclone copies and syncs files between a local disk and some seventy storage services, and it can also serve any of them. rclone serve webdav remote:path puts one folder of a remote behind a WebDAV listener, so that a file manager, a phone or a backup tool can use it as a network drive. A common way to run it is one folder per user under a shared remote: one process serves store:tenant-a, another serves store:tenant-b, and each user is told the address of their own.

The folder is the boundary. Out of the box serve webdav asks for no login, so whoever can reach the port is a client, and the only thing between that client and the rest of the remote is rclone’s promise to serve tenant-a and nothing above it. Up to 1.75.1 that promise could be broken with two requests.

Two requests

Tenant A’s process serves tenant-a/. The remote also holds tenant B’s folder and an administrator’s configuration file. Before anything else, I checked that A could see only its own file and could not reach B’s:

tree before : admin.conf  tenant-a/a.txt  tenant-b/b.txt

PROPFIND /                    : / /a.txt
GET /../tenant-b/b.txt        : 404

Then, from A’s listener, with no credentials:

MKCOL /..                     : 201
MKCOL /../tenant-b            : 201    (it already exists)
PUT   /../tenant-b/b.txt      : 201
PUT   /../admin.conf          : 201
MKCOL /../tenant-c            : 201
PUT   /../tenant-c/planted.txt: 201

tree after  : admin.conf  tenant-a/a.txt  tenant-b/b.txt
              tenant-c/planted.txt
tenant-b/b.txt : TENANT-A-CLOBBERED-TENANT-B
admin.conf     : ADMIN-CONFIG-REPLACED

The first request is the whole trick: MKCOL /.. asks the server to create a directory called .. at the top of the share. After that, every write addressed to /../something lands next to tenant-a/ instead of inside it. A client could create files and directories there, overwrite any file whose name it knew, MOVE and COPY files and whole directories out of its own folder, and change modification times. Deletion took one extra step: a DELETE of a file the client had never touched came back 404, but after one PUT over it, the same DELETE returned 204 and the file was gone. In the per-user layout, the names of the other folders are not secrets, because they are the user names.

A 201 for a directory nobody made

Four layers each pass the .. along without looking at it.

The WebDAV protocol handling comes from golang.org/x/net/webdav, which hands the request path to the file system it is given exactly as it arrived. That is documented, and the library’s own file systems clean the path themselves. rclone’s adapter, cmd/serve/webdav/webdav.go, did not. Its Mkdir was four lines and none of them was a check:

	dir, leaf, err := VFS.StatParent(name)
	if err != nil {
		return err
	}
	_, err = dir.Mkdir(leaf)

StatParent("/..") splits the path into a parent of "" and a leaf of "..", and the empty parent is the root of rclone’s virtual file system (the VFS), the layer every serve command and rclone mount share. The root’s Dir.Mkdir then builds the new directory’s path with path.Join(d.path, name), and the root’s own path is the empty string. Go’s path.Join cleans its result, and path.Join("a", "..") is ".", but with an empty first argument there is nothing for the .. to cancel, so path.Join("", "..") is "..". That is the only directory where this works, which is why the escape is exactly one level up. One level is enough, because one level up is where the other users’ folders are.

The VFS then asks the backend to make the directory. For the FTP backend I used as storage, the backend joins its own root onto the name: path.Join("tenant-a", "..") is ".", and its mkdir returns success immediately for ".", since that directory obviously exists. So the backend was never asked to do anything, the MKCOL returned 201, and the VFS added a child of the root whose path was ... Every file created under it got a path starting with ../, and the backend joined those onto its root the same way. PUT /../admin.conf became a write to tenant-a/../admin.conf, which is admin.conf.

Not every backend does that join first. rclone’s own fix for an earlier serve restic advisory lists the ones that resolve the .. (webdav, ftp, sftp, http and memory) and the ones that encode it into a harmless file name (local and s3). I checked both halves. Over the local backend every request still returns 201, and the file lands in a directory whose name is two full-width dots (U+FF0E), inside the share. Over the webdav backend, the same two requests wrote above the share. The VFS node also lives only as long as the directory cache, five minutes by default, but a fresh MKCOL /.. replants it, so a client always sends the two requests together.

What it could not do

Reading what was already above the share was refused. A GET of a file the client had not written returned 404, and a PROPFIND of /.. listed nothing. That is because rclone’s August fix for two other advisories makes every directory listing drop entries whose path escapes the root, and the VFS looks names up through those listings. The node called .. therefore looked empty, so nothing already in it could be found by name. That is also why the score is 7.5 with no confidentiality impact. My first run said otherwise: a GET above the share returned 200, and it turned out the probe had already overwritten that file, so what came back was my own bytes. I re-ran the reads before any write, and they were 404.

The released binaries showed when that changed. I ran the same probe against seven of them, from v1.71.0 to v1.75.1. On every release up to v1.75.0, the read above the share was open too. It closes at v1.75.1, the first release with the listing filter, and the write stays open on all seven. I did not find a lower bound: v1.71.0, the oldest release I ran, is affected, and the code involved is older than that.

There was one read I did not test, and the vendor’s fix names it: moving a file or directory from outside the share into it made it readable. I only drove MOVE and COPY in the other direction.

Safe for someone else’s reasons

Besides webdav, rclone has eight other serve front ends (dlna, docker, ftp, http, nfs, restic, s3 and sftp), and they all sit on the same VFS. I ran five of them (ftp, sftp, http, restic and s3) against the same backend, binary and fixture in one script, each with its own live control. Every escape attempt stayed inside the share.

Three of them were stopped by rclone’s own code: restic and s3 reject the path with 400, and http, which only reads, returned 404. The other two were not rclone code at all. In the ftp run, the client sent STOR ../E2.txt and the FTP library handed rclone STOR E2.txt, because it cleans paths before rclone sees them. The SFTP library does the same, which is why I drove sftp with a raw client rather than one that might clean the path itself. serve nfs relies on its NFS library too, which refuses .. when creating a directory, so the first request fails there. I could see in its source that file creation, renames and symlinks were not checked, but I did not run them, and the report listed that as an untested residual.

So webdav was the one front end with neither its own check nor a library doing the check for it. The pattern behind that was the reason I asked for more than a patch to webdav: this was the fifth path-confinement fix on rclone’s serve commands in four months, and each earlier one had fixed only the front end that was reported. A dependency update could remove a library’s cleaning without any change to rclone. I suggested two fixes: the same check serve restic already had, in webdav, and a check in the VFS itself, so that no front end could create a node called .. again.

Measuring the fix without letting curl fix it

Nick Craig-Wood, rclone’s maintainer, accepted the report four days later, requested a CVE, and posted a branch with both fixes. He agreed with the second one: “the chokepoint is the right place and we have put it there.” I built the branch and replayed the PoC, with the released v1.75.1 as the control:

                          v1.75.1    fix branch
MKCOL /..                    201        400
PUT   /../pwned.txt          201        400
MKCOL /%2e%2e                201        400
MOVE  hello -> /../moved     201        400

MKCOL /realdir, writes and moves inside the share, and PUT /a..b.txt stayed 201 on both. The first time I ran this, the branch returned 201 too, because curl resolves .. in a URL before sending it, so that run never put a .. on the wire. With --path-as-is the probe sends what it says, and the four rows above come from that run. I also told him that his new VFS check would never fire under serve ftp, because the FTP library normalises the name first. Removing the FTP front end’s own check was still safe, but only because of the library.

Two commits, and a second open front end

The fix shipped in 1.75.2 as two commits. 133ced103 makes serve webdav reject any request whose path has a . or .. element with 400 Bad Request. For COPY and MOVE it also checks the Destination header, after removing --baseurl the way the WebDAV handler does, because removing the prefix as a plain string turns /base../x into ../x. 2f43b99ab makes the VFS refuse ., .. and any name containing / in Dir.Create, Dir.Mkdir and Dir.Rename, with EINVAL, whatever front end or mount the name came from.

The second commit’s message settles the residual my report had left open. The NFS library checked names only on MKDIR, and not on CREATE, RENAME or SYMLINK, so serve nfs was exploitable the same way. My report had counted serve nfs as confined, with that residual noted. It was a second open front end, and the VFS check is its fix.

If you run rclone serve webdav over a remote path that is not the remote’s root, upgrade to 1.75.2 or later. Before you do, --read-only is a complete mitigation: with it, MKCOL /.. returns 405 and nothing is written above the share. Adding --user and --pass limits the attack to clients who can log in, which matters when those clients are the other tenants.


Advisory GHSA-44pm-2q5p-fm63 · CVE CVE-2026-116191 · CVSS 7.5 High (v3.1), AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N, on the advisory and the record; 8.7 High (v4.0) on the record · CWE CWE-22 · Fix 133ced103, 2f43b99ab · Affected <= 1.75.1 · Fixed 1.75.2

Reported 13 September · fixes committed 14 September · accepted 17 September, with a CVE requested from GitHub and a second, independent reporter credited as finder · fix branch reviewed and retested the same day · 1.75.2 and the advisory published 9 October, crediting me as reporter · CVE reserved and record published by VulnCheck 25 October, crediting me as reporter. The GitHub request was still unanswered and the advisory’s CVE field still empty at the time of writing.

Eurico Nicacio — @h3llh0und

← all posts Eurico Nicacio · h3llh0und