Kopia: RCE via SSH ProxyCommand Injection (CVE-2026-45695)
Critical Kopia RCE in passwordless servers via SSH ProxyCommand injection through repository-existence checks.
Severity score
CVSS 3.1- CVE
- CVE-2026-45695
- GHSA
- GHSA-2q4c-3mrw-63c3
- Attack vector
- Network
- Attack complexity
- Low
- Privileges required
- None
- User interaction
- None
- Scope
- Unchanged
- Confidentiality
- High
- Integrity
- High
- Availability
- High
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H Credits
Summary
CVE-2026-45695 is a critical unauthenticated remote code execution vulnerability affecting Kopia <= 0.22.3.
The issue appears when Kopia’s passwordless HTTP server mode is combined with repository probing and the SFTP backend’s support for launching an external OpenSSH process:
- Kopia HTTP server can be started with
--without-password, causing selected UI API routes to authorize requests without credentials. - The
/api/v1/repo/existsendpoint accepts attacker-controlled repository storage configuration and passes SFTP options intoblob.NewStorage. - When SFTP storage uses
externalSSH: true, Kopia appends user-controlledsshArgumentsdirectly to the OpenSSH command line. - OpenSSH supports
-oProxyCommand=<cmd>, which executes<cmd>through the user’s shell before any network connection is made.
Put together, those behaviors give a remote unauthenticated attacker a one-request path to command execution as the Kopia server process user.
Affected Versions
Affected:
github.com/kopia/kopia <= 0.22.3
Patched:
github.com/kopia/kopia >= 0.23.0
The advisory assigns severity Critical, CVSS 9.8:
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Root Cause
1. Unauthenticated UI API Access
In vulnerable versions, requireUIUser returns true when the server has no authenticator:
func requireUIUser(_ context.Context, rc requestContext) bool {
if rc.srv.getAuthenticator() == nil {
return true
}
if rc.srv.getOptions().UIUser == "" {
return false
}
user, _, _ := rc.req.BasicAuth()
return user == rc.srv.getOptions().UIUser
}
The vulnerable endpoint is registered as a UI route that may be called before a repository is connected:
m.HandleFunc("/api/v1/repo/exists", s.handleUIPossiblyNotConnected(handleRepoExists)).Methods(http.MethodPost)
See internal/server/server.go.
In practice, that means a server started with --without-password can expose this endpoint without credentials. That matters because the endpoint does not merely read local state; it builds storage from request-supplied configuration.
2. Attacker-Controlled Storage Configuration
handleRepoExists unmarshals the request body into a repository-existence request, then passes the provided storage configuration directly into blob.NewStorage:
func handleRepoExists(ctx context.Context, rc requestContext) (any, *apiError) {
var req serverapi.CheckRepositoryExistsRequest
if err := json.Unmarshal(rc.body, &req); err != nil {
return nil, unableToDecodeRequest(err)
}
st, err := blob.NewStorage(ctx, req.Storage, false)
if err != nil {
return nil, internalServerError(err)
}
defer st.Close(ctx)
...
}
See internal/server/api_repo.go.
This is the important boundary crossing. The attacker does not need an existing Kopia repository, nor do they need to influence a saved server-side configuration. They can provide a fresh storage configuration directly in the HTTP request and cause Kopia to instantiate it.
3. SFTP externalSSH Command Construction
The SFTP options include externally controlled fields:
ExternalSSH bool `json:"externalSSH"`
SSHCommand string `json:"sshCommand,omitempty"`
SSHArguments string `json:"sshArguments,omitempty"`
See repo/blob/sftp/sftp_options.go.
When externalSSH is enabled, Kopia builds the external SSH process arguments like this:
if opt.SSHArguments != "" {
cmdArgs = append(cmdArgs, strings.Split(opt.SSHArguments, " ")...)
}
cmdArgs = append(
cmdArgs,
opt.Username+"@"+opt.Host,
"-s", "sftp",
)
sshCommand := opt.SSHCommand
if sshCommand == "" {
sshCommand = "ssh"
}
cmd := exec.CommandContext(ctx, sshCommand, cmdArgs...)
See repo/blob/sftp/sftp_storage.go.
The use of exec.CommandContext avoids the most obvious form of shell injection in Kopia itself. Kopia is not building a command string and handing it to sh -c.
The problem is one layer lower: Kopia gives the attacker control over OpenSSH options, and OpenSSH has options whose documented behavior includes executing a local command.
The dangerous option is:
-oProxyCommand=<command>
OpenSSH executes the proxy command via the user’s shell and then runs the SSH transport over that command’s standard input/output. This happens before SSH needs a successful connection to the configured host, so the host in the request can be effectively irrelevant.
Exploit Chain
The exploit path is short. An attacker needs a Kopia server running a vulnerable version, exposed over HTTP, and started in passwordless mode. From there, the request targets /api/v1/repo/exists with an SFTP storage configuration that enables externalSSH and supplies a malicious OpenSSH option.
The full set of required conditions is:
- Kopia server is running a vulnerable version,
<= 0.22.3. - The server was started with
--without-password. - The server is reachable by the attacker over HTTP.
- The attacker sends a POST request to
/api/v1/repo/exists. - The JSON body specifies SFTP storage with
externalSSH: true. - The JSON body includes
sshArgumentscontaining an OpenSSHProxyCommand.
When the endpoint calls blob.NewStorage, Kopia instantiates the SFTP storage backend. That backend launches ssh; ssh parses ProxyCommand; and the proxy command runs attacker-controlled shell code.
Impact
The direct impact is arbitrary code execution as the Kopia server process user.
Potential consequences include:
- Reading repository configuration and local files accessible to the Kopia process.
- Exfiltrating repository credentials, tokens, SSH keys, or environment variables.
- Modifying or deleting repository data.
- Running arbitrary local commands.
- Installing persistence under the compromised user account.
- Using the host as a pivot point into internal networks.
- Denial of service by killing the Kopia process or exhausting resources.
CWE Mapping
CWE-78: Improper Neutralization of Special Elements used in an OS Command
CWE-306: Missing Authentication for Critical Function
Why exec.CommandContext Was Not Sufficient
exec.CommandContext("ssh", args...) protects against shell metacharacters being interpreted by Kopia’s shell, because Kopia does not invoke a shell directly.
However, it does not protect against dangerous options in the invoked program.
For example, these are materially different risks:
; rm -rf / # shell metacharacter injection into the parent shell
-oProxyCommand # legitimate ssh option that causes ssh to invoke a shell
The vulnerable code defended against the first category but allowed the second.
This is a common trap: treating “argv-safe” process spawning as equivalent to safe command execution. It is only safe when the invoked program’s argument grammar is safe for untrusted input, or when dangerous features in that grammar are explicitly blocked.
Mitigation Analysis
PR #5354 mitigates the reported remote unauthenticated attack by preventing passwordless insecure servers from listening on non-loopback interfaces by default.
The new validation is in internal/insecureserverbind/insecureserverbind.go.
The restriction applies only when all of these are true:
return insecure && withoutPassword && !allowDangerousNetwork
Before server startup, Kopia now validates the configured listen address:
if err := insecureserverbind.ValidateListenAddressIfRestricted(
c.serverStartInsecure,
c.serverStartWithoutPassword,
c.serverStartAllowDangerousUnauthenticatedNetwork,
c.sf.serverAddress,
); err != nil {
return errors.Wrap(err, "listen address not allowed for insecure server without password")
}
See cli/command_server_start.go.
It also validates the actual bound listener after Listen, which matters for systemd socket activation or cases where the configured address does not fully represent the actual socket:
if err := insecureserverbind.ValidateListenerAddrIfRestricted(
c.serverStartInsecure,
c.serverStartWithoutPassword,
c.serverStartAllowDangerousUnauthenticatedNetwork,
l.Addr(),
); err != nil {
l.Close()
return errors.Wrap(err, "insecure server bind validation")
}
See cli/command_server_tls.go.
Allowed unauthenticated binds include:
127.0.0.0/8 loopback
::1 loopback
localhost
Unix domain sockets
Rejected binds include:
0.0.0.0
[::]
empty host / all interfaces
non-loopback IPs
non-localhost hostnames
There is also an explicit hidden override:
--allow-extremely-dangerous-unauthenticated-server-on-the-network
That preserves the old behavior for operators who explicitly opt into the risk, while changing the default away from a remotely reachable unauthenticated server.
Recommended Remediation
The primary remediation is straightforward:
Upgrade Kopia to >= 0.23.0.
Operators should also treat passwordless server mode as local-only:
- Do not run Kopia server with —without-password on any network-accessible interface.
- Bind passwordless servers only to 127.0.0.1, ::1, localhost, or a Unix socket.
- Avoid using the dangerous override flag except in isolated test environments.
- Require authentication for UI/API access.
- Place Kopia behind a trusted reverse proxy only if authentication and network ACLs are correctly enforced.