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 thePATH
.
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] = SCRIPT; // 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
- About the history of mitigation of setuid scripts attacks: https://www.in-ulm.de/~mascheck/various/shebang/#setuid
- Perl has given a lot of though about privileged perl scripts, here lies good advice for other languages as well: https://perldoc.perl.org/perlsec
8. Advertisement
Did you like what you read ?
You can help me write more by:
- renting a guix VPS from me,
- hiring me for a consulting gig: software development, cybersecurity audit and training, cryptocurrency forensics, etc. see my personal page,
- letting me teach you Python, or spreading the word about this course,
- or buying a very, very secure laptop from me.
9. Changelog
- Provided a more generic C wrapper, with no limit on the number of arguments