💾 Archived View for thrig.me › tech › openbsd › sftp-only.gmi captured on 2024-12-17 at 10:45:13. Gemini links have been rewritten to link to archived content
-=-=-=-=-=-=-
Someone asked on an IRC channel whether it was possible to create an account only allowed to use SFTP (also SCP, those being more or less the same thing, especially in recent releases of OpenSSH) on OpenBSD 7.5. SFTP, no login shell, nor proxies nor port forwardings nor any of the other spiffy things SSH offers. One idea is to use the shell /sbin/nologin, so they do not have a login, but that Does Not Workâ„¢ producing various wacky messages.
$ sftp testuser@testhost ... Received message too long 1416128883 Ensure the remote shell produces no output for non-interactive sessions.
You may see similar errors with rsync, which also wants a "clean" channel that /sbin/nologin does not provide, as it prints a "not available" message. The shell rc files probably should also not make noise by default, as this may conflict with rsync, and anyways no news is good news.
$ ssh testuser@testhost ... This account is currently not available. Connection to 192.0.42.1 closed.
The conflict here is that sftp wants a remote shell ("Ensure the remote shell produces …") but we do not want to give the user a shell to do nefarious things with, such as running random shell commands, executing local exploit code, or who knows what. Systems are simply more tidy the fewer users you have on them. On the other hand, users can help pay the rent?
The code for nologin by way of /usr/src/sbin/nologin/nologin.c is pretty short, and reading the source code may give us ideas; minus the license stuff and reformatted to fit this document better yields:
... #define _PATH_NOLOGIN_TXT "/etc/nologin.txt" #define DEFAULT_MESG "This account is currently not available.\n" int main(int argc, char *argv[]) { int nfd; ssize_t nrd; char nbuf[BUFSIZ]; ... nfd = open(_PATH_NOLOGIN_TXT, O_RDONLY); if (nfd == -1) { write(STDOUT_FILENO, DEFAULT_MESG, strlen(DEFAULT_MESG)); exit (1); } while ((nrd = read(nfd, nbuf, sizeof(nbuf))) != -1 && nrd != 0) write(STDOUT_FILENO, nbuf, nrd); close (nfd); exit (1); }
Maybe we can make /etc/nologin.txt an empty file, which should print no message (there being nothing to read(2) from an empty file), and thus keep sftp happy?
$ doas touch /etc/nologin.txt $ sftp testuser@testhost ... Connection closed
Nope, sftp in addition to not wanting a message also needs the remote shell to stay open while sftp sftps.
$ doas rm /etc/nologin.txt
Be sure to clean up test files so that the system goes back to "the usual" state, or use a throwaway test virt that you can easily rebuild to "the usual" state, or use configuration management so that the system is better kept in "the usual" state. Or you do you, but don't complain to me when you have to waste time debugging weird issues because some configuration got set awry somehow. (On the other hand, having systems in weird state gives you practice debugging weird issues; users often suffice to create such situations, especially if they have root, like that one user who had been using `kill -9` on random things and so a cron job of theirs had somehow gotten into a very broken state, at which point they asked for help.)
Can we use a shell that remains open and meanwhile prints nothing?
// linger.c - linger by blocking on a pipe read #include <err.h> #include <stdio.h> #include <stdlib.h> #include <unistd.h> int main(int argc, char *argv[]) { int fds[2]; char buf[1]; if (pledge("stdio", NULL) == -1) err(1, "pledge"); if (pipe(fds) != 0) err(1, "pipe"); while (1) read(fds[0], buf, 1); exit(0); }
This code blocks forever, but also blocks sftp from working when linger is set as the shell for the testuser:
$ make linger cc -O2 -pipe -o linger linger.c $ pkglocate sbin/linger $ doas cp linger /usr/local/sbin/ $ doas ed /etc/shells ... $ doas chsh testuser ... $ sftp testuser@localhost ... ^C $ sftp nottestuser@localhost Connected to localhost. sftp> quit $
At this point one might ask (or try to find out) how exactly OpenSSH does the sftp thing, as our "do nothing" "login" "shell" needs to do something more than nothing, but not too much.
Some poking around in the SSH server code under /usr/src/usr.bin/ssh (not /usr/src/usr.sbin/sshd) reveals some interesting lines in session.c:
if (s->is_subsystem == SUBSYSTEM_INT_SFTP_ERROR) { error("Connection from %s: refusing non-sftp session", remote_id); printf("This service allows sftp connections only.\n"); fflush(NULL); exit(1); } else if (s->is_subsystem == SUBSYSTEM_INT_SFTP) { extern int optind, optreset; int i; char *p, *args; setproctitle("%s@%s", s->pw->pw_name, INTERNAL_SFTP_NAME); args = xstrdup(command ? command : "sftp-server"); for (i = 0, (p = strtok(args, " ")); p; (p = strtok(NULL, " "))) if (i < ARGV_MAX - 1) argv[i++] = p; argv[i] = NULL; optind = optreset = 1; __progname = argv[0]; exit(sftp_server_main(i, argv, s->pw)); }
So there could be a SFTP-only mode, or instead the "non-sftp session" error is from trying to do non-SFTP things with a SFTP connection? From the argv related code we might expect that a command is executed, somewhere—but why does that involve a login shell? (As you might tell, I don't yet know the answers; someone who knows how this all works would likely skip directly to the most pertinent parts.) SUBSYSTEM_INT_SFTP may relate to the internal-sftp thing mentioned in sshd_config(5), so enabling that for our test user is certainly something to try. Also I've never used any of the chroot options for OpenSSH so that might be good to explore.
The ChrootDirectory must contain the necessary files and directories to support the user's session. For an interactive session this requires at least a shell, typically sh(1), and basic /dev nodes such as null(4), zero(4), stdin(4), stdout(4), stderr(4), and tty(4) devices. For file transfer sessions using SFTP no additional configuration of the environment is necessary if the in-process sftp-server is used, though sessions which use logging may require /dev/log inside the chroot directory on some operating systems (see sftp-server(8) for details).
— sshd_config(5)
That sounds a bit of a bother to setup so let's try only internal-sftp via the /etc/ssh/sshd_config file. And also to turn off various protocol features we do not want our restricted access user to access.
Match User testuser AllowAgentForwarding no AllowStreamLocalForwarding no AllowTcpForwarding no ForceCommand internal-sftp X11Forwarding no
This appears to work, even with the testuser shell set back to /sbin/nologin, as ForceCommand internal-sftp replaces the shell that is usually run.
$ doas rcctl restart sshd sshd(ok) sshd(ok) $ sftp testuser@localhost ... Connected to localhost. sftp> ls / /altroot /backup /bin /bsd /bsd.rd /bsd.sp /dev /etc /home /mnt /root /sbin /sys /tmp /usr /var sftp> quit $ getent passwd testuser testuser:*:1001:1001:Test User,,,:/home/testuser:/sbin/nologin
Also be sure to do some other tests, like can they run a command?
$ ssh testuser@localhost uptime ... This service allows sftp connections only.
Probably SFTP-only users should be put into a group, rather than listing them individually in sshd_config, to minimize the number of user entries in sshd_config.
What if we want a custom shell? This is a bit more complicated, but would allow users to both SFTP files and run certain commands. Care must be taken to not give them access to tools that allow a shell escape. Editors come to mind, though system(3) may appear in all sorts of programs. Therefore we must implement enough of the sh(1) interface to run shell commands and provide access to the SFTP subsystem, but no more.
Some amount of time fiddling with code later, it may be good from time to time to check ps(1) or to otherwise account for processes being run, as this may give clues as to how the SFTP system is invoked when not using internal-sftp. Process tracing can also work (exec ktrace -di ...) if you do not mind digging through the lots of output produced.
testuser 90408 0.0 0.0 168 808 ?? Ip Sat12AM 0:00.00 linger -c /usr/libexec/sftp-server testuser 78733 0.0 0.0 168 792 ?? Sp Sat12AM 0:05.17 linger -c /usr/libexec/sftp-server
Our shell will need to support "-c" in addition to a command line interface, and will run only a few programs, uptime and sftp-server, with "exit" being an internal command. Probably you can see how more programs could be added, scaling problems with unveil, and so forth if you actually want to adapt this sketch to use in production.
$ CFLAGS="-ledit -lcurses" make few cc -ledit -lcurses -o few few.c $ doas cp few /usr/local/sbin/ $ sftp testuser@localhost ... Connected to localhost. sftp> ls / /altroot /backup /bin /bsd /bsd.rd /bsd.sp /dev /etc /home /mnt /root /sbin /sys /tmp /usr /var sftp> exit $ ssh testuser@localhost testuser@127.0.0.1's password: ... ] pwd ] /usr/libexec/sftp-server -few: use sftp for SFTP access ] uptime 11:08PM up 23 days, 25 mins, 3 users, load averages: 0.00, 0.02, 0.21 ] exit Connection to 127.0.0.1 closed.
Note how the user input is kept well away from any execv(3) call, to say less of passing said input directly to system(3). This should make it more difficult for an attacker to run arbitrary programs. A more complicated shell may need to use a proper tokenizer to accept random user input to programs, ideally without also allowing them to run something like the conventional `rm -rf /`.