CVE-2025-24366 (Go)

SFTPGo is an open source, event-driven file transfer solution. SFTPGo supports execution of a defined set of commands via SSH. Besides a set of default commands some optional commands can be activated, one of them being `rsync`. It is disabled in the default configuration and it is limited to the local filesystem, it does not work with cloud/remote storage backends. Due to missing sanitization of the client provided `rsync` command, an authenticated remote user can use some options of the rsync command to read or write files with the permissions of the SFTPGo server process. This issue was fixed in version v2.6.5 by checking the client provided arguments. Users are advised to upgrade. There are no known workarounds for this vulnerability.

Weakness:
CWE-94: name: Improper Control of Generation of Code ('Code Injection')
description: The product constructs all or part of a code segment using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the syntax or behavior of the intended code segment.
CWE-77: name: Improper Neutralization of Special Elements used in a Command ('Command Injection')
description: The product constructs all or part of a command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended command when it is sent to a downstream component.
CWE-78: name: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
description: The product constructs all or part of an OS command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended OS command when it is sent to a downstream component.

Repository: https://github.com/drakkan/sftpgo

Vulnerable code:
file_path: internal/sftpd/ssh_cmd.go
snippet: func (c *sshCommand) getSystemCommand() (systemCommand, error) {
	command := systemCommand{
		cmd:            nil,
		fs:             nil,
		fsPath:         "",
		quotaCheckPath: "",
	}
	if err := common.CheckClosing(); err != nil {
		return command, err
	}
	args := make([]string, len(c.args))
	copy(args, c.args)
	var fsPath, quotaPath string
	sshPath := c.getDestPath()
	fs, err := c.connection.User.GetFilesystemForPath(sshPath, c.connection.ID)
	if err != nil {
		return command, err
	}
	if len(c.args) > 0 {
		var err error
		fsPath, err = fs.ResolvePath(sshPath)
		if err != nil {
			return command, c.connection.GetFsError(fs, err)
		}
		quotaPath = sshPath
		fi, err := fs.Stat(fsPath)
		if err == nil && fi.IsDir() {
			// if the target is an existing dir the command will write inside this dir
			// so we need to check the quota for this directory and not its parent dir
			quotaPath = path.Join(sshPath, "fakecontent")
		}
		if strings.HasSuffix(sshPath, "/") && !strings.HasSuffix(fsPath, string(os.PathSeparator)) {
			fsPath += string(os.PathSeparator)
			c.connection.Log(logger.LevelDebug, "path separator added to fsPath %q", fsPath)
		}
		args = args[:len(args)-1]
		args = append(args, fsPath)
	}
	if err := c.isSystemCommandAllowed(); err != nil {
		return command, errUnsupportedConfig
	}
	if c.command == "rsync" {
		// we cannot avoid that rsync creates symlinks so if the user has the permission
		// to create symlinks we add the option --safe-links to the received rsync command if
		// it is not already set. This should prevent to create symlinks that point outside
		// the home dir.
		// If the user cannot create symlinks we add the option --munge-links, if it is not
		// already set. This should make symlinks unusable (but manually recoverable)
		if c.connection.User.HasPerm(dataprovider.PermCreateSymlinks, c.getDestPath()) {
			if !util.Contains(args, "--safe-links") {
				args = append([]string{"--safe-links"}, args...)
			}
		} else {
			if !util.Contains(args, "--munge-links") {
				args = append([]string{"--munge-links"}, args...)
			}
		}
	}
	c.connection.Log(logger.LevelDebug, "new system command %q, with args: %+v fs path %q quota check path %q",
		c.command, args, fsPath, quotaPath)
	cmd := exec.Command(c.command, args...)
	uid := c.connection.User.GetUID()
	gid := c.connection.User.GetGID()
	cmd = wrapCmd(cmd, uid, gid)
	command.cmd = cmd
	command.fsPath = fsPath
	command.quotaCheckPath = quotaPath
	command.fs = fs
	return command, nil
}

Fixed code:
file_path: internal/sftpd/ssh_cmd.go
snippet: func (c *sshCommand) getSystemCommand() (systemCommand, error) {
	command := systemCommand{
		cmd:            nil,
		fs:             nil,
		fsPath:         "",
		quotaCheckPath: "",
	}
	if err := common.CheckClosing(); err != nil {
		return command, err
	}
	args := make([]string, len(c.args))
	copy(args, c.args)
	var fsPath, quotaPath string
	sshPath := c.getDestPath()
	fs, err := c.connection.User.GetFilesystemForPath(sshPath, c.connection.ID)
	if err != nil {
		return command, err
	}
	if len(c.args) > 0 {
		var err error
		fsPath, err = fs.ResolvePath(sshPath)
		if err != nil {
			return command, c.connection.GetFsError(fs, err)
		}
		quotaPath = sshPath
		fi, err := fs.Stat(fsPath)
		if err == nil && fi.IsDir() {
			// if the target is an existing dir the command will write inside this dir
			// so we need to check the quota for this directory and not its parent dir
			quotaPath = path.Join(sshPath, "fakecontent")
		}
		if strings.HasSuffix(sshPath, "/") && !strings.HasSuffix(fsPath, string(os.PathSeparator)) {
			fsPath += string(os.PathSeparator)
			c.connection.Log(logger.LevelDebug, "path separator added to fsPath %q", fsPath)
		}
		args = args[:len(args)-1]
		args = append(args, fsPath)
	}
	if err := c.isSystemCommandAllowed(); err != nil {
		return command, errUnsupportedConfig
	}
	if c.command == "rsync" {
		if !canAcceptRsyncArgs(args) {
			c.connection.Log(logger.LevelWarn, "invalid rsync command, args: %+v", args)
			return command, errors.New("invalid or unsupported rsync command")
		}
		// we cannot avoid that rsync creates symlinks so if the user has the permission
		// to create symlinks we add the option --safe-links to the received rsync command if
		// it is not already set. This should prevent to create symlinks that point outside
		// the home dir.
		// If the user cannot create symlinks we add the option --munge-links, if it is not
		// already set. This should make symlinks unusable (but manually recoverable)
		if c.connection.User.HasPerm(dataprovider.PermCreateSymlinks, c.getDestPath()) {
			if !util.Contains(args, "--safe-links") {
				args = slices.Insert(args, len(args)-2, "--safe-links")
			}
		} else {
			if !util.Contains(args, "--munge-links") {
				args = slices.Insert(args, len(args)-2, "--munge-links")
			}
		}
	}
	c.connection.Log(logger.LevelDebug, "new system command %q, with args: %+v fs path %q quota check path %q",
		c.command, args, fsPath, quotaPath)
	cmd := exec.Command(c.command, args...)
	uid := c.connection.User.GetUID()
	gid := c.connection.User.GetGID()
	cmd = wrapCmd(cmd, uid, gid)
	command.cmd = cmd
	command.fsPath = fsPath
	command.quotaCheckPath = quotaPath
	command.fs = fs
	return command, nil
}