diff --git a/modules/module-list.nix b/modules/module-list.nix index 043d1ee66..772b936bb 100644 --- a/modules/module-list.nix +++ b/modules/module-list.nix @@ -8,6 +8,7 @@ ./security/pki ./security/sandbox ./security/sudo.nix + ./security/wrappers ./system ./system/base.nix ./system/checks.nix diff --git a/modules/security/wrappers/default.nix b/modules/security/wrappers/default.nix new file mode 100644 index 000000000..a80a4aca6 --- /dev/null +++ b/modules/security/wrappers/default.nix @@ -0,0 +1,193 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.security; + + parentWrapperDir = dirOf cfg.wrapperDir; + + wrapperType = lib.types.submodule ({ name, config, ... }: { + options = with lib; { + source = mkOption { + type = types.path; + description = mdDoc "The absolute path to the program to be wrapped."; + }; + program = mkOption { + type = with types; nullOr str; + default = name; + description = mdDoc "The name of the wrapper program. Defaults to the attribute name."; + }; + owner = mkOption { + type = types.str; + description = mdDoc "The owner of the wrapper program."; + }; + group = mkOption { + type = types.str; + description = mdDoc "The group of the wrapper program."; + }; + permissions = mkOption { + type = types.str; + default = "u+rx,g+x,o+x"; + description = mdDoc "The permissions to set on the wrapper."; + }; + setuid = mkOption { + type = types.bool; + default = false; + description = mdDoc "Whether to add the setuid bit to the wrapper program."; + }; + setgid = mkOption { + type = types.bool; + default = false; + description = mdDoc "Whether to add the setgid bit to the wrapper program."; + }; + # codesign = mkOption { + # type = types.bool; + # default = false; + # description = mdDoc "Whether to codesign the wrapper program."; + # }; + }; + }); + + mkWrappedPrograms = + builtins.map + (opts: mkWrapper opts) + (builtins.attrValues cfg.wrappers); + + securityWrapper = sourceProg : pkgs.pkgsStatic.callPackage ./wrapper.nix { + inherit sourceProg; + + # glibc definitions of insecure environment variables + # + # We extract the single header file we need into its own derivation, + # so that we don't have to pull full glibc sources to build wrappers. + # + # They're taken from pkgs.glibc so that we don't have to keep as close + # an eye on glibc changes. Not every relevant variable is in this header, + # so we maintain a slightly stricter list in wrapper.c itself as well. + # unsecvars = lib.overrideDerivation (pkgs.srcOnly pkgs.glibc) + # ({ name, ... }: { + # name = "${name}-unsecvars"; + # installPhase = '' + # mkdir $out + # cp sysdeps/generic/unsecvars.h $out + # ''; + # }); + }; + + mkWrapper = + { program + , source + , owner + , group + , permissions + , setuid + , setgid + , codesign ? false + , ... + }: + let + codesigned = if codesign + then '' + # codesign ${source} to "$wrapperDir/${program}" INSTEAD OF the next line + cp ${securityWrapper source}/bin/security-wrapper "$wrapperDir/${program}" + '' + else '' + cp ${securityWrapper source}/bin/security-wrapper "$wrapperDir/${program}" + ''; + in + '' + ${codesigned} + + # Prevent races + chmod 0000 "$wrapperDir/${program}" + chown ${owner}:${group} "$wrapperDir/${program}" + + chmod "u${if setuid then "+" else "-"}s,g${if setgid then "+" else "-"}s,${permissions}" "$wrapperDir/${program}" + ''; +in +{ + # probably not necessary since these options never existed in nix-darwin? + imports = [ + (lib.mkRemovedOptionModule [ "security" "setuidOwners" ] "Use security.wrappers instead") + (lib.mkRemovedOptionModule [ "security" "setuidPrograms" ] "Use security.wrappers instead") + ]; + + ###### interface + options.security = { + wrappers = lib.mkOption { + type = lib.types.attrsOf wrapperType; + default = {}; + example = lib.literalExpression + '' + { + # a setuid root program + doas = + { setuid = true; + owner = "root"; + group = "wheel"; + source = "''${pkgs.doas}/bin/doas"; + }; + + + # a setgid program + locate = + { setgid = true; + owner = "root"; + group = "mlocate"; + source = "''${pkgs.locate}/bin/locate"; + }; + } + ''; + description = lib.mdDoc '' + This option effectively allows adding setuid/setgid bits and/or changing + file ownership and permissions without directly modifying it. This works + by creating a wrapper program under the {option}`security.wrapperDir` + directory, which is then added to the shell `PATH`. + ''; + }; + wrapperDir = lib.mkOption { + type = lib.types.path; + default = "/run/wrappers/bin"; + internal = true; + description = lib.mdDoc '' + This option defines the path to the wrapper programs. It + should not be overridden. + ''; + }; + # codesignIdentity = lib.mkOption { + # type = lib.types.str; + # default = "-"; + # description = lib.mdDoc "Identity to use for codesigning."; + # }; + }; + + ###### implementation + config = { + environment.extraInit = '' + # Wrappers override other bin directories. + export PATH="${cfg.wrapperDir}:$PATH" + ''; + + system.activationScripts.wrappers.text = '' + echo "setting up wrappers..." >&2 + if ! test -e /run/wrappers; then mkdir /run/wrappers; fi + + # We want to place the tmpdirs for the wrappers to the parent dir. + wrapperDir=$(mktemp --directory --tmpdir="${parentWrapperDir}" wrappers.XXXXXXXXXX) + chmod a+rx $wrapperDir + + ${builtins.concatStringsSep "\n" mkWrappedPrograms} + + if test -L ${cfg.wrapperDir}; then + # Atomically replace the symlink + # See https://axialcorps.com/2013/07/03/atomically-replacing-files-and-directories/ + old=$(readlink -f ${cfg.wrapperDir}) + ln --symbolic --force --no-dereference $wrapperDir ${cfg.wrapperDir}-tmp + mv --no-target-directory ${cfg.wrapperDir}-tmp ${cfg.wrapperDir} + rm --force --recursive $old + else + # For initial setup + ln --symbolic $wrapperDir ${cfg.wrapperDir} + fi + ''; + }; +} diff --git a/modules/security/wrappers/wrapper.c b/modules/security/wrappers/wrapper.c new file mode 100644 index 000000000..b6e4a8b38 --- /dev/null +++ b/modules/security/wrappers/wrapper.c @@ -0,0 +1,128 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +// #include +// #include +// #include +// #include +// #include +// #include +// #include +// #include +// #include +// #include +// #include + +// imported from glibc +// #include "unsecvars.h" + +#ifndef SOURCE_PROG +#error SOURCE_PROG should be defined via preprocessor commandline +#endif + +// aborts when false, printing the failed expression +#define ASSERT(expr) ((expr) ? (void) 0 : assert_failure(#expr)) + +extern char **environ; + +// Wrapper debug variable name +static char *wrapper_debug = "WRAPPER_DEBUG"; + +static noreturn void assert_failure(const char *assertion) { + fprintf(stderr, "Assertion `%s` in NixOS's wrapper.c failed.\n", assertion); + fflush(stderr); + abort(); +} + +// These are environment variable aliases for glibc tunables. +// This list shouldn't grow further, since this is a legacy mechanism. +// Any future tunables are expected to only be accessible through GLIBC_TUNABLES. +// +// They are not included in the glibc-provided UNSECURE_ENVVARS list, +// since any SUID executable ignores them. This wrapper also serves +// executables that are merely granted ambient capabilities, rather than +// being SUID, and hence don't run in secure mode. We'd like them to +// defend those in depth as well, so we clear these explicitly. +// +// Except for MALLOC_CHECK_ (which is marked SXID_ERASE), these are all +// marked SXID_IGNORE (ignored in secure mode), so even the glibc version +// of this wrapper would leave them intact. +#define UNSECURE_ENVVARS_TUNABLES \ + "MALLOC_CHECK_\0" \ + "MALLOC_TOP_PAD_\0" \ + "MALLOC_PERTURB_\0" \ + "MALLOC_MMAP_THRESHOLD_\0" \ + "MALLOC_TRIM_THRESHOLD_\0" \ + "MALLOC_MMAP_MAX_\0" \ + "MALLOC_ARENA_MAX\0" \ + "MALLOC_ARENA_TEST\0" + +#define UNSECURE_ENVVARS \ + "GCONV_PATH\0" \ + "GETCONF_DIR\0" \ + "HOSTALIASES\0" \ + "LD_AUDIT\0" \ + "LD_DEBUG\0" \ + "LD_DEBUG_OUTPUT\0" \ + "LD_DYNAMIC_WEAK\0" \ + "LD_HWCAP_MASK\0" \ + "LD_LIBRARY_PATH\0" \ + "LD_ORIGIN_PATH\0" \ + "LD_PRELOAD\0" \ + "LD_PROFILE\0" \ + "LD_SHOW_AUXV\0" \ + "LD_USE_LOAD_BIAS\0" \ + "LOCALDOMAIN\0" \ + "LOCPATH\0" \ + "MALLOC_TRACE\0" \ + "NIS_PATH\0" \ + "NLSPATH\0" \ + "RESOLV_HOST_CONF\0" \ + "RES_OPTIONS\0" \ + "TMPDIR\0" \ + // GLIBC_TUNABLES_ENVVAR \ + +int main(int argc, char **argv) { + ASSERT(argc >= 1); + + // argv[0] goes into a lot of places, to a far greater degree than other elements + // of argv. glibc has had buffer overflows relating to argv[0], eg CVE-2023-6246. + // Since we expect the wrappers to be invoked from either $PATH or /run/wrappers/bin, + // there should be no reason to pass any particularly large values here, so we can + // be strict for strictness' sake. + ASSERT(strlen(argv[0]) < 512); + + int debug = getenv(wrapper_debug) != NULL; + + // Drop insecure environment variables explicitly + // + // glibc does this automatically in SUID binaries, but we'd like to cover this: + // + // a) before it gets to glibc + // b) in binaries that are only granted ambient capabilities by the wrapper, + // but don't run with an altered effective UID/GID, nor directly gain + // capabilities themselves, and thus don't run in secure mode. + // + // We're using musl, which doesn't drop environment variables in secure mode, + // and we'd also like glibc-specific variables to be covered. + // + // If we don't explicitly unset them, it's quite easy to just set LD_PRELOAD, + // have it passed through to the wrapped program, and gain privileges. + for (char *unsec = UNSECURE_ENVVARS_TUNABLES UNSECURE_ENVVARS; *unsec; unsec = strchr(unsec, 0) + 1) { + if (debug) { + fprintf(stderr, "unsetting %s\n", unsec); + } + unsetenv(unsec); + } + + execve(SOURCE_PROG, argv, environ); + + fprintf(stderr, "%s: cannot run `%s': %s\n", + argv[0], SOURCE_PROG, strerror(errno)); + + return 1; +} diff --git a/modules/security/wrappers/wrapper.nix b/modules/security/wrappers/wrapper.nix new file mode 100644 index 000000000..8c795e74f --- /dev/null +++ b/modules/security/wrappers/wrapper.nix @@ -0,0 +1,19 @@ +{ stdenv, sourceProg, debug ? false }: +# For testing: +# $ nix-build -E 'with import {}; pkgs.callPackage ./wrapper.nix { sourceProg = "${pkgs.hello}/bin/hello"; debug = true; }' +stdenv.mkDerivation { + name = "security-wrapper-${baseNameOf sourceProg}"; + dontUnpack = true; + CFLAGS = [ + ''-DSOURCE_PROG="${sourceProg}"'' + ] ++ (if debug then [ + "-Werror" "-Og" "-g" + ] else [ + "-Wall" "-O2" + ]); + dontStrip = debug; + installPhase = '' + mkdir -p $out/bin + $CC $CFLAGS ${./wrapper.c} -o $out/bin/security-wrapper + ''; +} diff --git a/modules/system/activation-scripts.nix b/modules/system/activation-scripts.nix index 68e01b592..0d39f7f79 100644 --- a/modules/system/activation-scripts.nix +++ b/modules/system/activation-scripts.nix @@ -70,6 +70,7 @@ in ${cfg.activationScripts.keyboard.text} ${cfg.activationScripts.fonts.text} ${cfg.activationScripts.nvram.text} + ${cfg.activationScripts.wrappers.text} ${cfg.activationScripts.postActivation.text}