Skip to content

lsetstat@openssh.com with PERMISSIONS answers SSH_FX_OK and discards the request on platforms without lchmod #827

Description

@kmoneil

The SFTP server answers SSH_FX_OK to an lsetstat@openssh.com request carrying
SSH_FILEXFER_ATTR_PERMISSIONS and changes neither the link's mode nor the target's, on any
platform where os.chmod does not support follow_symlinks=False (Linux, notably — there is no
lchmod, and fchmodat(AT_SYMLINK_NOFOLLOW) answers ENOTSUP).

A client that asked not to follow the link is told the permission change happened. It did not, and
nothing in the response distinguishes that from success.

This is narrow: it is one flag, not the extension. ACMODTIME over the same request works
correctly — the link's own timestamp really changes — so lsetstat itself is fine.

Reproducer

asyncssh client against asyncssh server, one process, no third-party code.

import asyncio, os, stat, tempfile
from pathlib import Path

import asyncssh


class NoAuthServer(asyncssh.SSHServer):
    def begin_auth(self, username):
        return False


def mode_of(path, *, follow):
    return format(stat.S_IMODE((os.stat if follow else os.lstat)(path).st_mode), "04o")


async def main():
    root = Path(tempfile.mkdtemp())
    target, link = root / "target", root / "link"
    target.write_bytes(b"payload")
    os.chmod(target, 0o600)
    os.symlink(target, link)

    server = await asyncssh.create_server(
        NoAuthServer, "127.0.0.1", 0,
        server_host_keys=[asyncssh.generate_private_key("ssh-ed25519")],
        sftp_factory=True,
    )
    port = server.sockets[0].getsockname()[1]

    async with asyncssh.connect(
        "127.0.0.1", port, username="probe", known_hosts=None
    ) as conn, conn.start_sftp_client() as sftp:
        print(f"before: link {mode_of(link, follow=False)}  target {mode_of(target, follow=True)}")
        await sftp.chmod(str(link), 0o640, follow_symlinks=False)
        print("server answered SSH_FX_OK (no exception raised)")
        print(f"after:  link {mode_of(link, follow=False)}  target {mode_of(target, follow=True)}")

        # The control: the same request shape with ACMODTIME instead, which works.
        before = os.lstat(link).st_mtime_ns, os.stat(target).st_mtime_ns
        await sftp.utime(str(link), (1_000_000_000, 1_000_000_000), follow_symlinks=False)
        print(f"link mtime changed:   {os.lstat(link).st_mtime_ns != before[0]}")
        print(f"target mtime changed: {os.stat(target).st_mtime_ns != before[1]}")

    server.close()
    await server.wait_closed()


asyncio.run(main())

Output, asyncssh 2.24.0, CPython 3.13.15, Linux 6.12 aarch64:

before: link 0777  target 0600
server answered SSH_FX_OK (no exception raised)
after:  link 0777  target 0600
link mtime changed:   True
target mtime changed: False

(0777 is what a Linux symlink always reports; the point is that neither it nor the target moved.)

Where it comes from

SFTPServer.lsetstat calls _setstat(..., follow_symlinks=False), and that function swallows the
exception CPython raises — asyncssh/sftp.py:632-637 in 2.24.0, unchanged on develop at
a787030:

    if attrs.permissions is not None:
        try:
            os.chmod(path, stat.S_IMODE(attrs.permissions),
                     follow_symlinks=follow_symlinks)
        except NotImplementedError: # pragma: no cover
            pass

On Linux, os.chmod not in os.supports_follow_symlinks, so that call raises
NotImplementedError: chmod: follow_symlinks unavailable on this platform and the request is
discarded. _process_lsetstat then returns SSH_FX_OK, because as far as it can tell the handler
succeeded.

The two neighbouring clauses (os.chown, os.utime) have the same except NotImplementedError: pass, but both of those are in os.supports_follow_symlinks on Linux, so they never fire —
which is why ACMODTIME works and only PERMISSIONS is dropped.

SSH_FILEXFER_ATTR_UIDGID is untested here: an unprivileged reproducer can only chown a file to
the uid it already has, so "nothing moved" is the expected result either way.

Why it seems worth fixing rather than documenting

A caller passing follow_symlinks=False is making a security decision — do not act on whatever
this link points at. Three outcomes are possible and SSH_FX_OK names none of them: the link's
mode changed, the target's changed (the link was followed, which is the thing being refused), or
nothing changed. There is no way to tell them apart without statting both files afterwards.

OpenSSH's sftp-server refuses the same request on the same kernel, with SSH_FX_FAILURE. So two
servers advertising the same extension give opposite answers, and the one that says yes is the one
that did nothing.

Suggested fix

SSH_FX_OP_UNSUPPORTED is what the protocol has for this, and asyncssh already produces it
SFTPServerHandler._process_packet maps NotImplementedError to FX_OP_UNSUPPORTED
(sftp.py:6058). The swallow one layer down is the only thing preventing it.

Removing just that try/except (nothing else) gives the client:

SFTPOpUnsupported  code=8  reason='Operation not supported: lsetstat'

Whether the other two clauses should also stop swallowing is a judgement call — on Linux they are
unreachable, but the same silent-success shape is available to them on a platform where those
os.* calls lack follow_symlinks support.

Worth noting that the # pragma: no cover on the clause means no test exercises it, which is
presumably why this has gone unnoticed.

Versions

  • asyncssh 2.24.0 (latest release), and develop at a787030 is unchanged
  • CPython 3.13.15
  • Linux 6.12.76 aarch64

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions