Almost all systems ignore the setuid and setgid bits on scripts. (This is a not a bug or oversight, it's an important security feature; more on that later.)
The common work-around is to use a small setuid and/or setgid binary wrapper. The basic version in @jeremy-sturdivant 's answer is a good start, but it doesn't allow you to pass any arguments. For that you need to pass argv to execve, after modifying it to point at the actual script:
#include <sys/types.h>
#include <stdio.h> /* perror */
#include <unistd.h> /* execve, geteuid, setuid */
int main(int argc, char **argv, char **envp) {
setuid(geteuid());
*argv = "/real/path/to/script";
execve(*argv, argv, envp);
perror(*argv);
return 127;
}
If you want setgid instead of setuid, just change the setuid... line to:
setgid(getegid());
This is still fairly basic, and lacks error checking around setuid/setgid. It also relies on the installer (you) to have checked that there are no other leaks such as writable ancestor directories.
But why are setuid and setgid ignored on scripts?
First it's helpful to understand what happens when a script is invoked.
Like any program, a script is invoked by the execve kernel call, which accepts 3 parameters: filename (a string), argv (an array of strings), and envp (another array of strings). By convention argv[0] is the "same" as filename, but this is only an approximation; it may omit or change the path, and/or it may have a - prefaced to it to indicate that this should be the start of a new login session. (envp contains the environment variables; it isn't important for this discussion.)
The argv and envp parameters are normally passed through unchanged to the argv and envp parameters of main in the new program.
However if a program file begins with #!, the kernel will read the first line of that file to obtain an interpreter filename, and up to one interpreter option. Then it will modify the supplied argv argument:
- The original
argv[0] will be discarded and replaced by filename, then
- the
filename argument is set to the interpreter filename.
- one or two more elements are inserted at the front of
argv[], the interpreter filename, and the interpreter option (if given).
Then execve starts over with these new arguments.
This means that when the interpreter starts, the script filename is passed into its main as an ordinary element of argv, and the interpreter simply opens that filename and starts reading it.
This means that there's a small interval between when the setuid bit is inspected during the execve syscall, and when the script interpreter opens the script for reading. During that, an attacker could replace filename with their own script, which would then run with the new UID or GID.
Wait, you said "almost all" systems. What about the others?
A few systems open the script during the execve syscall and invoke the interpreter with /dev/fd/3 as the "name" of the script. This means there's no security risk from setuid or setgid operation, but it has the drawback that $0 in a script is a lot less useful.