37

While I understand the idea of SUID is to let an unprivileged user run a program as a privileged user, I have found that SUID usually doesn't work on a shell script without some workarounds. My question is, I don't really understand the dichotomy between a shell script and a binary program. It seems that whatever you can do with a shell script, you can also do it with C and compile it into a binary. If SUID is not secure for a shell script, then it's also not secure for binaries. So why would shell scripts but not binaries be prohibited from using SUID?

Cyker
  • 1,613
  • 11
  • 17

2 Answers2

64

There is a race condition inherent to the way shebang (#!) is typically implemented:

  1. The kernel opens the executable, and finds that it starts with #!.
  2. The kernel closes the executable and opens the interpreter instead.
  3. The kernel inserts the path to the script to the argument list (as argv[1]), and executes the interpreter.

If setuid scripts are allowed with this implementation, an attacker can invoke an arbitrary script by creating a symbolic link to an existing setuid script, executing it, and arranging to change the link after the kernel has performed step 1 and before the interpreter gets around to opening its first argument. For this reason, all modern unices ignore the setuid bit when they detect a shebang.

One way to secure this implementation would be for the kernel to lock the script file until the interpreter has opened it (note that this must prevent not only unlinking or overwriting the file, but also renaming any directory in the path). But unix systems tend to shy away from mandatory locks, and symbolic links would make a correct lock feature especially difficult and invasive. I don't think anyone does it this way.

A few unix systems implement secure setuid shebang using an additional feature: the path /dev/fd/N refers to the file already opened on file descriptor N (so opening /dev/fd/N is roughly equivalent to dup(N)).

  1. The kernel opens the executable, and finds that it starts with #!. Let's say the file descriptor for the executable is 3.
  2. The kernel opens the interpreter.
  3. The kernel inserts /dev/fd/3 the argument list (as argv[1]), and executes the interpreter.

All modern unix variants including Linux implement /dev/fd, but most do not allow setuid scripts. OpenBSD, NetBSD and Mac OS X support it if you enable a non-default kernel setting. On Linux, people have written patches to allow it but those patches never got merged. Sven Mascheck's shebang page has a lot of information on shebang across unices, including setuid support.


In addition, programs running with elevated privileges have inherent risks that are typically harder to control in higher-level programming languages unless the interpreter was specifically designed for it. The reason is that the programming language runtime's initialization code may perform actions with elevated privileges, based on data that's inherited from the lower-privilege caller, before the program's own code has had the opportunity to sanitize this data. The C runtime does very little for the programmer, so C programs have a better opportunity to take control and sanitize data before anything bad can happen.

Let's assume you've managed to make your program run as root, either because your OS supports setuid shebang or because you've used a native binary wrapper (such as sudo). Have you opened a security hole? Maybe. The issue here is not about interpreted vs compiled programs. The issue is whether your runtime system behaves safely if executed with privileges.

  • Any dynamically linked native binary executable is in a way interpreted by the dynamic loader (e.g. /lib/ld.so), which loads the dynamic libraries required by the program. On many unices, you can configure the search path for dynamic libraries through the environment (LD_LIBRARY_PATH is a common name for the environment variable), and even load additional libraries into all executed binaries (LD_PRELOAD). The invoker of the program can execute arbitrary code in that program's context by placing a specially-crafted libc.so in $LD_LIBRARY_PATH (amongst other tactics). All sane systems ignore the LD_* variables in setuid executables.

  • In shells such as sh, csh and derivatives, environment variables automatically become shell parameters. Through parameters such as PATH, IFS, and many more, the invoker of the script has many opportunities to execute arbitrary code in the shell scripts's context. Some shells set these variables to sane defaults if they detect that the script has been invoked with privileges, but I don't know that there is any particular implementation that I would trust.

  • Most runtime environments (whether native, bytecode or interpreted) have similar features. Few take special precautions in setuid executables, though the ones that run native code often don't do anything fancier than dynamic linking (which does take precautions).

  • Perl is a notable exception. It explicitly supports setuid scripts in a secure way. In fact, your script can run setuid even if your OS ignored the setuid bit on scripts. This is because perl ships with a setuid root helper that performs the necessary checks and reinvokes the interpreter on the desired scripts with the desired privileges. This is explained in the perlsec manual. It used to be that setuid perl scripts needed #!/usr/bin/suidperl -wT instead of #!/usr/bin/perl -wT, but on most modern systems, #!/usr/bin/perl -wT is sufficient.

Note that using a native binary wrapper does nothing in itself to prevent these problems. In fact, it can make the situation worse, because it might prevent your runtime environment from detecting that it is invoked with privileges and bypassing its runtime configurability.

A native binary wrapper can make a shell script safe if the wrapper sanitizes the environment. The script must take care not to make too many assumptions (e.g. about the current directory) but this goes. You can use sudo for this provided that it's set up to sanitize the environment. Blacklisting variables is error-prone, so always whitelist. With sudo, make sure that the env_reset option is turned on, that setenv is off, and that env_file and env_keep only contain innocuous variables.

All these considerations apply equally for any privilege elevation: setuid, setgid, setcap.

Recycled from https://unix.stackexchange.com/questions/364/allow-setuid-on-shell-scripts/2910#2910

Gilles 'SO- stop being evil'
  • 50,912
  • 13
  • 120
  • 179
  • Was going to say (though you touch on this towards the end) -- I've made a habit of binary wrappers in security-sensitive environments the past, but only when using `execve()` to pass an explicit environment (and called the script an a fully-qualified, hardcoded path not writable by unprivileged users). Using a non-`*e` `exec*` call is trouble, but calls that replace the environment very much do exist. – Charles Duffy Sep 21 '18 at 15:59
  • I have to admit I'm disappointed that #!/usr/bin/suidperl isn't required as a means of preventing accidentally setting the suid bit on a script not designed to have it. – Joshua Sep 21 '18 at 18:21
  • 1
    Note that the GNU dynamic linker at least not only ignores the `LD_*` variables but also unsets them, which means that even if you do a `setuid(geteuid())`, other programs that you execute from your setuid program/script will not be affected by those. – Stéphane Chazelas Sep 22 '18 at 12:32
  • 1
    May be worth pointing out that the environment variables are not the only things that are passed along/preserved across execve() (which brings the privilege escalation). There's also signal disposition (see what happens when you ignore SIGPIPE or SICHLD for instance), open (and closed like for 0,1,2) fds, controlling terminal, cwd, limits (lowering many of those can trip many software), umask... You want to minimize the code that runs with elevated privilege and write it very carefully. Running a whole shell and whole commands within the script is the last thing you want to do. – Stéphane Chazelas Sep 22 '18 at 12:40
15

Primarily because

Many kernels suffer from a race condition which can allow you to exchange the shellscript for another executable of your choice between the times that the newly exec()ed process goes setuid, and when the command interpreter gets started up. If you are persistent enough, in theory you could get the kernel to run any program you want.

as well as other reasons found at that link (but the kernel race condition being the most pernicious). Scripts, of course, load differently than binary programs, which is where the fault creeps in.

You might be amused to read this 2001 Dr. Dobb's article - which goes through 6 steps towards writing more secure SUID shell scripts, only to reach step 7:

Lesson Seven -- Don't use SUID shell scripts.

Even after all our work, it is nearly impossible to create safe SUID shell scripts. (It is impossible on most systems.) Because of these problems, some systems (e.g., Linux) won't honor SUID on shell scripts.

This history talks about which variants had fixes for the race condition; the list is larger than you'd think... but setuid scripts are still largely discouraged because of other problems or because it's easier to discourage them than to remember whether you're running on a safe or unsafe variant.

This was a big enough problem, back in the day, that it's instructive to read about how aggressively Perl approached compensating for it.

gowenfawr
  • 71,975
  • 17
  • 161
  • 198