Writing a secure privileged bash script

This meat of this article comes from an email from Chet Ramey, the current bash maintainer.

Chet was kind enough to explain to me how to write a privileged bash script in such a way that users of the script can not use it for privilege escalation.

All quotes in this article come from his email, unless otherwise specified.

Chet increased my awareness of all the ways users can inject arbitrary code into the script, and showed me how to close those doors one by one.

And then how to close the window, the cat door, and light a fire in the chimney.

Hurried readers may skip to the TL;DR at the end.

1. Dynamic loading

If you have an environment where a user can use LD_LIBRARY_PATH and load a shared library with library function replacements before running your script, all bets are off.

Dynamic linking refers to the idea of keeping the compiled form of the library in some conventional place on the system, outside of the programs that use it. The programs fetch the code at runtime.

Static linking refers the simpler option of writing all the functions used by the program in the program file itself.

Proponents of dynamic linking say that it:

  • reduces the memory footprint of the executables,
  • makes security patching easier,
  • allows for a better use of program cache.

Opponents will say that the above comes out at best as not clear cut, and that the security risk is so high that dynamic linking is simply not worth it.

If you want to deep dive into this debate, you will find a good starting point in Sta.li's FAQ: https://sta.li/faq/

To see just how many opportunities for privilege escalation dynamic linking offers from standard tools, take a look at: https://gtfobins.github.io/#+library%20load

The bash binary itself uses dynamic loading (on my system, at least):

guix shell bash gcc which coreutils -- bash -c 'ldd $(which bash) | fold -s'
	linux-vdso.so.1 (0x00007fff8a73b000)
	libreadline.so.8 =>
/gnu/store/lxfc2a05ysi7vlaq0m3w5wsfsy0drdlw-readline-8.1.2/lib/libreadline.so.8
(0x00007f2b501cc000)
	libhistory.so.8 =>
/gnu/store/lxfc2a05ysi7vlaq0m3w5wsfsy0drdlw-readline-8.1.2/lib/libhistory.so.8
(0x00007f2b501bf000)
	libncursesw.so.6 =>
/gnu/store/bcc053jvsbspdjr17gnnd9dg85b3a0gy-ncurses-6.2.20210619/lib/libncursesw
.so.6 (0x00007f2b5014d000)
	libgcc_s.so.1 =>
/gnu/store/930nwsiysdvy2x5zv1sf6v7ym75z8ayk-gcc-11.3.0-lib/lib/libgcc_s.so.1
(0x00007f2b50133000)
	libc.so.6 =>
/gnu/store/gsjczqir1wbz8p770zndrpw4rnppmxi3-glibc-2.35/lib/libc.so.6
(0x00007f2b4ff35000)

/gnu/store/gsjczqir1wbz8p770zndrpw4rnppmxi3-glibc-2.35/lib/ld-linux-x86-64.so.2
(0x00007f2b50225000)

Thankfully, when running with elevated privileges, the loader disregards its user-defined configuration.

Permitting user control over dynamically linked libraries would be disastrous for setuid/setgid programs if special measures weren't taken. Therefore, in the GNU loader (which loads the rest of the program on program start-up), if the program is setuid or setgid these variables (and other similar variables) are ignored or greatly limited in what they can do. The loader determines if a program is setuid or setgid by checking the program's credentials; if the uid and euid differ, or the gid and the egid differ, the loader presumes the program is setuid/setgid (or descended from one) and therefore greatly limits its abilities to control linking. If you read the GNU glibc library source code, you can see this; see especially the files elf/rtld.c and sysdeps/generic/dl-sysdep.c. This means that if you cause the uid and gid to equal the euid and egid, and then call a program, these variables will have full effect. Other Unix-like systems handle the situation differently but for the same reason: a setuid/setgid program should not be unduly affected by the environment variables set.

https://tldp.org/HOWTO/Program-Library-HOWTO/shared-libraries.html

The dynamic linker on most operating systems will remove variables that can control dynamic linking from the environment of set-user-ID executables, including sudo. Depending on the operating system this may include _RLD*, DYLD_*, LD_*, LDR_*, LIBPATH, SHLIB_PATH, and others. These type of variables are removed from the environment before sudo even begins execution and, as such, it is not possible for sudo to pre‐ serve them.

man sudoers

Unless you:

  • use a non-standard way to run your script with elevated privileges,
  • or explicitely call enable -f (which loads a builtin from a shared library),

you don't have to worry about dynamic loading.

2. Functions and aliases

If you call foo in a bash script, before looking into your PATH for an executable named foo, bash will look for, in order:

  • an alias,
  • a special builtin (when in POSIX mode),
  • a function,
  • a builtin.

Users define aliases and functions in startup files or through the BASH_ENV variable:

[Y]ou can pass shell functions from one bash invocation to another in the environment. If you set BASH_ENV, you can get a non- interactive shell to read $BASH_ENV as a startup file, where you can define functions to override builtins (bash finds functions before builtins, so you can write a function named `cd', for instance), define aliases, and turn on alias expansion, which is normally disabled in a non-interactive shell.

[…] One beneficial side effect of running bash -p, for your purposes, is that it prevents the shell from inheriting shell functions from the environment and prevents non-interactive shells from reading and executing commands from $BASH_ENV at startup.

2.1. bash -p

To erect a first line of defence against functions and aliases, call bash with the -p option.

Most of the time, bash will force your hand anyway, to protect you from shooting yourself in the foot:

[If not run with -p], bash will turn off setuid mode and set the effective uid to the real uid.

2.2. Backslash quoting

To prevent alias expansion, call foo as \foo.

2.3. Unset and unalias

Imagine the attacker has set a foo function. \unset -f foo will remove this function foo, thus unmasking the actually intended foo command.

\unalias -a will do the same for all aliases.

3. PATH injection

Another way for the attacker to choose what to execute when the script calls foo, is to edit the PATH environment variable.

mallory can get her code executed instead of the actually intended foo:

  • by putting her own foo executable in, say, /home/mallory/bin,
  • and putting /home/mallory/bin/ before anything else in the PATH.

To prevent such an attack, one should favor builtins bash constructs (echo, true, etc.) over commands, and use the full command path when calling a binary (but this may lead to portability issues).

4. Symlink attacks

This link describes in detail two attacks against setuid scripts: http://www.faqs.org/faqs/unix-faq/faq/part4/section-7.html

By changing the name of the executable through a symbolic link, one can get the kernel to execute an interactive shell or a custom script, with elevated privileges.

To prevent shooting yourself in the foot that way, your system may ignore the setuid bit of a program written in an interpreted language (i.e. a text file that starts with a shebang (#!)).

To circumvent this protection, write a wrapper in a compiled language. This wrapper also works as a way against the aforementioned two attacks.

One can see how clever it is for the operating system to ignore the setuid bit of interpreted scripts: circumventing this security measure also mitigates the attack vector.

Here is the skeleton of an example wrapper written in C11: With thanks to lobste.rs user muvlon for reminding me that execv expects a NULL-terminated array. :

#include <unistd.h>
#include <stdio.h>

#define SCRIPT "/bin/somescript.sh"

int main(int argc, char** argv){
  char* bash_argv[argc // To hold the same arguments as the initial call
                  +1   // for the -p option to bash
                  +1   // for the path to the wrapper script
                  +1]; // For the null byte at the end
  bash_argv[0] = "somescript.sh";  // argv[0] is supposed to be the name
                                   // of the executable
  bash_argv[1] = "-p";
  bash_argv[2] = MOUNT9P_SH; // The path to the script
  for(int i=1; i<= argc; i++){
    bash_argv[i+2] = argv[i];  // Copying the argc-1 arguments over
                               // and the null byte at the end
  }
  execv("/bin/bash", bash_argv);
  return 0;
}

Notice how we use the -p flag for bash here. Other interpreted languages sometimes provide a similar flag.

5. setuid or sudo ?

I like to set the setuid bit to start a script with elevated privileges. To the same end, most users know about the sudo command. I dislike sudo for two reasons:

  • unless carefully reviewed, its configuration may give more privileges than intended to users,
  • the executable and the privilege escalation configuration live in two separate places, and I like it when my software has a single source of truth.

In contrast, a setuid bit has no side effect, it only impacts its program. The configuration stays close to the program, in the metadata of the program file itself.

But this is a matter of taste and the advice in this article should apply to both cases.

6. Conclusion and TL;DR

Here is the code that Chet suggests one starts their scripts with, as a way to prevent the attacks described here ; the comments are his.

I like the fact that Chet suggests multiple layers of defence. For example, running non-interactively, with -p, and unaliasing everything with \unalias -a seems like enough, but \shopt -u expand_aliases is still there just in case.

# if we are worried somehow about inheriting a function for unset or exec,
# set posix mode, then unset it later. this turns on alias expansion, so
# make sure to quote the special builtin names you're using
POSIXLY_CORRECT=1

# make sure to run with bash -p to prevent inheriting functions. you can
# do this (if the script does not need to run setuid) or use the
# POSIXLY_CORRECT setting above (as long as you run set +o posix as done below)
case $SHELLOPTS in
*privileged*)   ;;
*)      \exec /bin/bash -p $0 "$@" ;;
esac

# unset is a special builtin and will be found before functions; quoting it
# will prevent alias expansion
# add any other shell builtins or commands you're concerned about
\unset -f command builtin unset shopt set unalias
\unset -f read true : exit echo printf

# remove all aliases
\unalias -a
# and make sure we're not expanding aliases or running in posix mode
\shopt -u expand_aliases
set +o posix

# Once you run this code, you can use true/:/echo/printf and be sure you're
# getting the builtins without them being overridden by shell functions or
# aliases.

7. See also

8. Advertisement

If you want to enjoy discussions and experiments about these corners of UNIX security, come and join us at the dam ! For a measly 10€/year, you will share a GNU Guix server with people from all over the world.

9. Changelog

  • <2024-04-15 Mon> Provided a more generic C wrapper, with no limit on the number of arguments