sftp-chroot

Working homedir-jailed SFTP

View project on GitHub

This project aims to provide a working solution for home directory-jailed SFTP using OpenSSH-server's internal-sftp subsystem and automount(8).

Motivation

FTP daemons like ProFTPd can restrict users to their home directories (and do so by default). This is often desirable for shared systems.

SSH daemons like the OpenSSH Server lack that feature. While it is easy to only enable the SFTP facilities for certain users (by adding ForceCommand internal-sftp to the sshd_config file, for example), those users will still be able to traverse the entire file system, making it necessary for other users to pay close attention to their homedir modes.

Another problem is that the system might have sub accounts with a User Private Groups scheme, commonly seen at shared web hosting providers. For example:

  • Main account uid=1000(mle) gid=1000(mle) groups=(1000)mle,(500)sftp
    with homedir /home/mle
    should be able to access /home/mle via SFTP, including all sub-directories.
  • Sub account uid=1001(mle-sub) gid=1000(mle) groups=(1000)mle,(500)sftp
    with homedir /home/mle/sub
    should be able to access /home/mle/sub via SFTP, but not the rest of /home/mle.

If those two accounts both have a umask of 002, then the main account can access the sub account's files and directories. The sub account however should be prevented from leaving their own homedir while using SFTP, just like with FTP.

Existing solutions often advise administrators to have root-owned home directories only and have the SSH daemon use the chroot(2) system call to lock users into their home directories. While effective, this approach has distinct disadvantages:

  • Users can no longer change their home directory mode.
  • Users will see their own home directory as /.
    (This is also the case with most FTP daemons.)

This project presents a solution which avoids those problems.

Goals

  • Have an sftp user group which allows SFTP logins to its members but prevents them from using regular SSH.

  • Force SFTP users to be locked into their home directories to isolate them from all other homes and system directories.

  • Support sub-accounts with nested home directories (see above for an example).

  • Minimal configuration – the installation should set up everything by itself.

  • No administrative overhead – there should be no need, say, to change the way users are added to the system.

  • Have consistent path names – the pwd SFTP command should still show the real homedir path.

Ownership problem

While the internal-sftp sshd subsystem allows "lightweight" chrooting, that is, without requiring /dev/null, /bin/sh, etc, it still comes with a serious restriction:
The chroot dir has to be owned by root.

We don't want to force our users to have root-owned homedirs. Therefore, an sshd_config directive like ChrootDirectory %h won't work. (Also, the resulting directory hierarchy would look weird to the SFTP users: their homedir would appear to be located at /.)

We'll have to build an additional hierarchy for every user:
A root-owned directory like /jail/USERNAME can be used as one user's chroot filesystem. In there, they should find their homedir (and nothing more).
Symlinks won't work for that (they'd be evaluated inside the chroot filesystem, leading nowhere), and not even root is allowed to create directory hardlinks.

Bind mounts to the rescue

Bind mounts are a way to have one directory in two places, sort of like temporary directory hardlinks.

For example, this bind mount makes my home directory available at a second location:

$ ls -ld /mnt
drwxr-xr-x   2 root root 4,0K Jul 19  2015 /mnt/

# mount --bind  $HOME /mnt

$ ls -ld /mnt
drwxr-x---+ 77 mle  mle  4,0K Jun  2 00:20 /mnt/

As can be seen, the secondary location now has the same ownership and mode as my "real" homedir. Those two directories are now equal in every way. It will last until /mnt gets unmounted or the machine reboots.

Security problems with bind mounts

They also pose a security risk if applied too carelessly.
Usually, setting one's homedir mode to 0700 is sufficient to protect all of its contents. Even subdirectories and files with permissive modes like 0777 are still totally safe from other users simply because nobody can enter such a homedir at all.

Bind mounts circumvent this, just like hardlinks do!

For example:

# ls -la /root
drwx------  25 root root 4,0K Jun  2 01:09 /root/   # No one can get in here.
drwxrwxrwx   2 root root 4,0K Jun  2 01:09 /root/secrets/   # Perfectly safe, right?

# mount --bind /root/secrets /mnt

$ ls -ld /mnt
drwxrwxrwx   2 root root 4,0K Jun  2 01:09 /mnt/   # Oops!

Because /mnt can be reached by everyone, everybody can now effectively enter /root/secrets/. It is no longer protected by /root/'s restrictive mode.

It is therefore root's responsibility to be very careful with bind mounts in general, because they can be used to circumvent the mode restrictions of nested directories.

Using bind mounts for ssh chrooting

Keeping this problem in mind, we can create bind mounts to homedirs in root-owned locations. This evades sshd's restriction that the chroot dir must be root-owned: We can duplicate the /home hierarchy for one user inside their personal chroot filesystem.

We start by creating a base directory /jail for our bind mount points which is owned by root and has very restrictive modes, so no one can enter it. (This will still work because internal-sftp runs as root prior to chrooting, therefore it can enter the /jail directory, whereas logged-in non-root users cannot.)

# mkdir -m 0700  /jail

Now assume we want to allow chrooted SFTP logins for the mle-sub user.
Their homedir is /home/mle/sub.

# mkdir -p  /jail/mle-sub/home/mle/sub   # all owned by root
# mount --bind  /home/mle/sub  /jail/mle-sub/home/mle/sub

The chroot dir /jail/mle-sub is owned by root, so the ChrootDirectory directive will accept it. Inside it, there's a duplicate of the user's homedir and nothing else. That homedir duplicate is linked with the real homedir, so it really has the same permissions, ownership, and of course content.

Getting sshd on board

Appending this block to /etc/ssh/sshd_config tells sshd that all members of the sftp group are to be chrooted and can only ever use SFTP:

Match group sftp
    ChrootDirectory /jail/%u   # sshd will replace %u with the username.
    ForceCommand internal-sftp

So far so good

Now when the user mle-sub establishes a SFTP connection, sshd will first chroot the session to /jail/mle-sub, which works because those directories both belong to root.
As usual, internal-sftp will then try to change into the user's homedir, which is /home/mle/sub. That directory exists inside the chroot jail as well, so the directory change succeeds. home and home/mle are only empty, non-writable directories inside the jail, so the user cannot do any harm there, while home/mle/sub is a bind link to the real homedir.

The user ends up with an SFTP session inside their own home directory – or at least that's what it'll look like. Escaping that homedir with cd .. will only land them in an empty, non-writable directory, not in the real /home/mle.

Success!

Too many mounts, too much work

To enable SFTP logins like that for all sftp group members, we'd have to do this for every one of them:

  1. Replicate their homedir structure inside /jail
    (# mkdir -p /jail/$username/$homedir)
  2. Bind mount their homedir to the fake homedir inside the jail
    (# mount --bind $homedir /jail/$username/$homedir)

And of course, we'd have to do this after every reboot, because bind mounts are not durable.

We could enter all of them into our /etc/fstab, but after a few dozen entries both that file and mtab would get quite cluttered. Apart from that, it would necessitate changes to /etc/fstab every time a sftp user gets added, deleted, or has their homedir changed.

What if the system could automatically create and mount those directories when needed, unmounting and removing them again after the SFTP session ended?

Enter autofs

The autofs package contains the automount(8) daemon and the autofs4 kernel module. It is commonly used to establish NFS network mounts when they are needed, automatically unmounting them again if not accessed for some time.

It can read its directory configuration both from static map files, amounting to little more than fstab with automatic network retry and timeouts. It can also ask a script for its per-directory configuration every time a filesystem access to a missing directory is made. (Dynamic mappings like this can be used, for example, to have a zero-configuration "network shares" directory which will automatically connect to smb servers.)

We'll use this dynamic mapping scheme to automatically mount our jailed home directory links when they are needed.

Top configuration file

The top configuration files go in /etc/auto.master.d/ (which might have to be created first). We'll add our own configuration there:

$ cat > /etc/auto.master.d/jails.autofs
/jail program:/etc/autofs-sftp-jails.sh --timeout=20

This oneliner basically says that whenever someone tries to enter a non-existing directory in /jail, automount should call our autofs-sftp-jails.sh script which should then determine where to mount that directory from.

The dynamic map script

The autofs-sftp-jails.sh receives the non-existing relative directory as its first argument. Since only internal-sftp will ever enter /jail and we configured it to chroot the users to /jail/%u, we know that the autofs-sftp-jails.sh script will always receive the username of the SFTP user as its first argument.

With that, the script performs several security checks first:

  1. Making sure that /jail belongs to root and has a restrictive mode (0700), so that no one can go there to circumvent homedir access restrictions.
  2. Making sure that the $1 username actually exists and has an existing homedir.
  3. Making sure that the user's homedir is actually a directory, not a symlink, or autofs will happily create a bind link to the link target (!).

If everything looks good, the script echoes a single configuration line for the automount daemon like this:

echo "-fstype=bind  \"/$homedir\" \":$homedir\""

Let's break that down:

  1. -fstype=bind – pretty self-explanatory. (automount's default filesystem is NFS.)
  2. "/$homedir" – this tells automount which mount point to create/use. It is relative to the non-existing directory which triggered the script, so it'll be expanded to /jail/$username/$homedir.
  3. ":$homedir" – this tells automount where to find the mount target.

Effect

This means that as soon as the mle-sub user logs in via SFTP, sshd will chroot them to /jail/mle-sub. This directory does not yet exist, triggering the automount daemon, which will then create a bind mount at /jail/mle-sub/home/mle/sub leading to the real /home/mle/sub.
(The intermediary path components are automagically created, belonging to root with a mode of 0755, so thankfully our script won't have to do that.)

The SFTP user will end up in /jail/mle-sub/home/mle/sub which to them looks and feels like the real /home/mle/sub yet is completely isolated from the rest of the /home/mle tree!