Merge pull request #231673 from symphorien/suid_wrappers_userns

Changed files
+55 -6
nixos
modules
security
wrappers
tests
+34 -6
nixos/modules/security/wrappers/wrapper.c
···
+
#define _GNU_SOURCE
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
···
#include <syscall.h>
#include <byteswap.h>
+
// 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
+
#define MUSTSUCCEED(expr) ((expr) ? print_errno_and_die(#expr) : (void) 0)
extern char **environ;
···
static noreturn void assert_failure(const char *assertion) {
fprintf(stderr, "Assertion `%s` in NixOS's wrapper.c failed.\n", assertion);
+
fflush(stderr);
+
abort();
+
}
+
+
static noreturn void print_errno_and_die(const char *assertion) {
+
fprintf(stderr, "Call `%s` in NixOS's wrapper.c failed: %s\n", assertion, strerror(errno));
fflush(stderr);
abort();
}
···
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
···
ASSERT('/' == wrapper_dir[0]);
ASSERT('/' == self_path[len]);
-
// Make *really* *really* sure that we were executed as
-
// `self_path', and not, say, as some other setuid program. That
-
// is, our effective uid/gid should match the uid/gid of
-
// `self_path'.
+
// 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);
-
ASSERT(!(st.st_mode & S_ISUID) || (st.st_uid == geteuid()));
-
ASSERT(!(st.st_mode & S_ISGID) || (st.st_gid == getegid()));
+
// 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)));
+21
nixos/tests/wrappers.nix
···
out = machine.succeed(cmd_as_regular(cmd)).strip()
assert out == expected, "Expected {0} to output {1}, but got {2}".format(cmd, expected, out)
+
def test_as_regular_in_userns_mapped_as_root(cmd, expected):
+
out = machine.succeed(f"su -l regular -c '${pkgs.util-linux}/bin/unshare -rm {cmd}'").strip()
+
assert out == expected, "Expected {0} to output {1}, but got {2}".format(cmd, expected, out)
+
test_as_regular('${busybox pkgs}/bin/busybox id -u', '${toString userUid}')
test_as_regular('${busybox pkgs}/bin/busybox id -ru', '${toString userUid}')
test_as_regular('${busybox pkgs}/bin/busybox id -g', '${toString usersGid}')
···
test_as_regular('/run/wrappers/bin/sgid_root_busybox id -g', '0')
test_as_regular('/run/wrappers/bin/sgid_root_busybox id -rg', '${toString usersGid}')
+
test_as_regular_in_userns_mapped_as_root('/run/wrappers/bin/suid_root_busybox id -u', '0')
+
test_as_regular_in_userns_mapped_as_root('/run/wrappers/bin/suid_root_busybox id -ru', '0')
+
test_as_regular_in_userns_mapped_as_root('/run/wrappers/bin/suid_root_busybox id -g', '0')
+
test_as_regular_in_userns_mapped_as_root('/run/wrappers/bin/suid_root_busybox id -rg', '0')
+
+
test_as_regular_in_userns_mapped_as_root('/run/wrappers/bin/sgid_root_busybox id -u', '0')
+
test_as_regular_in_userns_mapped_as_root('/run/wrappers/bin/sgid_root_busybox id -ru', '0')
+
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')
+
# 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'))
machine.succeed(cmd_as_regular('/run/wrappers/bin/capsh_with_chown --has-p=CAP_CHOWN'))
machine.fail(cmd_as_regular('/run/wrappers/bin/capsh_with_chown --has-p=CAP_SYS_ADMIN'))
+
+
# test a few "attacks" against which the wrapper protects itself
+
machine.succeed("cp /run/wrappers/bin/suid_root_busybox{,.real} /tmp/")
+
machine.fail(cmd_as_regular("/tmp/suid_root_busybox id -u"))
+
+
machine.succeed("chmod u+s,a+w /run/wrappers/bin/suid_root_busybox")
+
machine.fail(cmd_as_regular("/run/wrappers/bin/suid_root_busybox id -u"))
'';
})