Change:
Describe the bug Wrappers for S[UG]ID binaries crash if they are executed in a way that doesn't cause escalation to happen (i.e. leaves effective UID unchanged), even though the binary has the S[UG]ID bit set. To Reproduce Steps to reproduce the behavior: Expected behavior I expect SUID wrapper to simply run the underlying binary if something (, mountpoint, etc.) prevented the effective UID from changing. Additional context I have initially encountered this issue while using bubblewrap (which sets on all the processes it starts in user namespaces), so it's not completely academic. I think the correct way to detect if escalation happened is to check whether UID and EUID are equal -- if they are we can be sure that we weren't escalated using the S[UD]ID mechanism. Some more care might be required due to capabilities, though. Notify maintainers -- the code of the dying wrappers is in nixos/modules/security/wrappers, and seems to have made most recent meaningful contributions there Metadata Maintainer information:
Ah, a better way of checking whether any escalation occurred (if not, we can simply exec the underlying binary without much care) is to check whether is set in the auxiliary vector (see ).
I just got surprised by this while trying out bubblewrap for the first time. To confirm, it dies on the same assertion:
I marked this as stale due to inactivity. &rarr;
The issue in my previous comment is still reproducible on nixpkgs master.

Discussion:
parentWrapperDir = dirOf wrapperDir; <del> securityWrapper = pkgs.callPackage ./wrapper.nix { inherit parentWrapperDir; </del> <ins> securityWrapper = sourceProg : pkgs.callPackage ./wrapper.nix { inherit sourceProg; </ins> }; fileModeType =

, ... }: '' <del> cp ${securityWrapper}/bin/security-wrapper "$wrapperDir/${program}" echo -n "${source}" > "$wrapperDir/${program}.real" </del> <ins> cp ${securityWrapper source}/bin/security-wrapper "$wrapperDir/${program}" </ins> # Prevent races chmod 0000 "$wrapperDir/${program}"

#include <syscall.h> #include <byteswap.h> <ins> #ifndef SOURCE_PROG #error SOURCE_PROG should be defined via preprocessor commandline #endif </ins> // aborts when false, printing the failed expression #define ASSERT(expr) ((expr) ? (void) 0 : assert_failure(#expr)) // aborts when returns non-zero, printing the failed expression and errno

extern char **environ; <del> // The WRAPPER_DIR macro is supplied at compile time so that it cannot // be changed at runtime static char *wrapper_dir = WRAPPER_DIR; </del> // Wrapper debug variable name static char *wrapper_debug = "WRAPPER_DEBUG";

return 0; } <del> int readlink_malloc(const char *p, char **ret) { size_t l = FILENAME_MAX+1; int r; for (;;) { char *c = calloc(l, sizeof(char)); if (!c) { return -ENOMEM; } ssize_t n = readlink(p, c, l-1); if (n < 0) { r = -errno; free(c); return r; } if ((size_t) n < l-1) { c[n] = 0; *ret = c; return 0; } free(c); l *= 2; } } </del> int main(int argc, char **argv) { ASSERT(argc >= 1); <del> char *self_path = NULL; int self_path_size = readlink_malloc("/proc/self/exe", &self_path); if (self_path_size < 0) { fprintf(stderr, "cannot readlink /proc/self/exe: %s", strerror(-self_path_size)); } unsigned int ruid, euid, suid, rgid, egid, sgid; MUSTSUCCEED(getresuid(&ruid, &euid, &suid)); MUSTSUCCEED(getresgid(&rgid, &egid, &sgid)); // If true, then we did not benefit from setuid privilege escalation, // where the original uid is still in ruid and different from euid == suid. int didnt_suid = (ruid == euid) && (euid == suid); // If true, then we did not benefit from setgid privilege escalation int didnt_sgid = (rgid == egid) && (egid == sgid); // Make sure that we are being executed from the right location, // i.e., `safe_wrapper_dir'.  This is to prevent someone from creating // hard link `X' from some other location, along with a false // `X.real' file, to allow arbitrary programs from being executed // with elevated capabilities. int len = strlen(wrapper_dir); if (len > 0 && '/' == wrapper_dir[len - 1]) --len; ASSERT(!strncmp(self_path, wrapper_dir, len)); ASSERT('/' == wrapper_dir[0]); ASSERT('/' == self_path[len]); // If we got privileges with the fs set[ug]id bit, check that the privilege we // got matches the one one we expected, ie that our effective uid/gid // matches the uid/gid of `self_path`. This ensures that we were executed as // `self_path', and not, say, as some other setuid program. // We don't check that if we did not benefit from the set[ug]id bit, as // can be the case in nosuid mounts or user namespaces. struct stat st; ASSERT(lstat(self_path, &st) != -1); // if the wrapper gained privilege with suid, check that we got the uid of the file owner ASSERT(!((st.st_mode & S_ISUID) && !didnt_suid) || (st.st_uid == euid)); // if the wrapper gained privilege with sgid, check that we got the gid of the file group ASSERT(!((st.st_mode & S_ISGID) && !didnt_sgid) || (st.st_gid == egid)); // same, but with suid instead of euid ASSERT(!((st.st_mode & S_ISUID) && !didnt_suid) || (st.st_uid == suid)); ASSERT(!((st.st_mode & S_ISGID) && !didnt_sgid) || (st.st_gid == sgid)); // And, of course, we shouldn't be writable. ASSERT(!(st.st_mode & (S_IWGRP | S_IWOTH))); // Read the path of the real (wrapped) program from <self>.real. char real_fn[PATH_MAX + 10]; int real_fn_size = snprintf(real_fn, sizeof(real_fn), "%s.real", self_path); ASSERT(real_fn_size < sizeof(real_fn)); int fd_self = open(real_fn, O_RDONLY); ASSERT(fd_self != -1); char source_prog[PATH_MAX]; len = read(fd_self, source_prog, PATH_MAX); ASSERT(len != -1); ASSERT(len < sizeof(source_prog)); ASSERT(len > 0); source_prog[len] = 0; close(fd_self); </del> // Read the capabilities set on the wrapper and raise them in to // the ambient set so the program we're wrapping receives the // capabilities too! <del> if (make_caps_ambient(self_path) != 0) { free(self_path); </del> <ins> if (make_caps_ambient("/proc/self/exe") != 0) { </ins> return 1; } <del> free(self_path); execve(source_prog, argv, environ); </del> <ins> execve(SOURCE_PROG, argv, environ); </ins> fprintf(stderr, "%s: cannot run `%s': %sn", <del> argv[0], source_prog, strerror(errno)); </del> <ins> argv[0], SOURCE_PROG, strerror(errno)); </ins> return 1; }

<del> { stdenv, linuxHeaders, parentWrapperDir, debug ? false }: </del> <ins> { stdenv, linuxHeaders, sourceProg, debug ? false }: </ins> # For testing: # $ nix-build -E 'with import <nixpkgs> {}; pkgs.callPackage ./wrapper.nix { parentWrapperDir = "/run/wrappers"; debug = true; }' stdenv.mkDerivation {

dontUnpack = true; hardeningEnable = [ "pie" ]; CFLAGS = [ <del> ''-DWRAPPER_DIR="${parentWrapperDir}"'' </del> <ins> ''-DSOURCE_PROG="${sourceProg}"'' </ins> ] ++ (if debug then [ "-Werror" "-Og" "-g" ] else [

test_as_regular_in_userns_mapped_as_root('/run/wrappers/bin/sgid_root_busybox id -g', '0') test_as_regular_in_userns_mapped_as_root('/run/wrappers/bin/sgid_root_busybox id -rg', '0') <ins> # Test that in nonewprivs environment the wrappers simply exec their target. test_as_regular('${pkgs.util-linux}/bin/setpriv --no-new-privs /run/wrappers/bin/suid_root_busybox id -u', '${toString userUid}') test_as_regular('${pkgs.util-linux}/bin/setpriv --no-new-privs /run/wrappers/bin/suid_root_busybox id -ru', '${toString userUid}') test_as_regular('${pkgs.util-linux}/bin/setpriv --no-new-privs /run/wrappers/bin/suid_root_busybox id -g', '${toString usersGid}') test_as_regular('${pkgs.util-linux}/bin/setpriv --no-new-privs /run/wrappers/bin/suid_root_busybox id -rg', '${toString usersGid}') test_as_regular('${pkgs.util-linux}/bin/setpriv --no-new-privs /run/wrappers/bin/sgid_root_busybox id -u', '${toString userUid}') test_as_regular('${pkgs.util-linux}/bin/setpriv --no-new-privs /run/wrappers/bin/sgid_root_busybox id -ru', '${toString userUid}') test_as_regular('${pkgs.util-linux}/bin/setpriv --no-new-privs /run/wrappers/bin/sgid_root_busybox id -g', '${toString usersGid}') test_as_regular('${pkgs.util-linux}/bin/setpriv --no-new-privs /run/wrappers/bin/sgid_root_busybox id -rg', '${toString usersGid}') </ins> # We are only testing the permitted set, because it's easiest to look at with capsh. machine.fail(cmd_as_regular('${pkgs.libcap}/bin/capsh --has-p=CAP_CHOWN')) machine.fail(cmd_as_regular('${pkgs.libcap}/bin/capsh --has-p=CAP_SYS_ADMIN'))