diff --git a/doc/release-notes/rl-2511.section.md b/doc/release-notes/rl-2511.section.md index 1fe5d9bf2b0d..b4c42d27ea84 100644 --- a/doc/release-notes/rl-2511.section.md +++ b/doc/release-notes/rl-2511.section.md @@ -9,7 +9,7 @@ -- Create the first release note entry in this section! +- The `boot.readOnlyNixStore` has been removed. Control over bind mount options on `/nix/store` is now offered by the `boot.nixStoreMountOpts` option. ## Other Notable Changes {#sec-nixpkgs-release-25.11-notable-changes} diff --git a/nixos/modules/system/boot/stage-2-init.sh b/nixos/modules/system/boot/stage-2-init.sh index fa2884d08708..e960f1bbcc39 100755 --- a/nixos/modules/system/boot/stage-2-init.sh +++ b/nixos/modules/system/boot/stage-2-init.sh @@ -61,25 +61,44 @@ else fi -# Make /nix/store a read-only bind mount to enforce immutability of -# the Nix store. Note that we can't use "chown root:nixbld" here +# Give /nix/store the defined mount options. +# Typically, this should be: +# - 'ro' to enforce immutability of the Nix store +# - 'nosuid' to enforce no suid binaries make it into the store and get executed by accident. +# suid-binaries should only exist in /run/wrappers. +# If an attacker can make the nix builder produce suid binaries in the store, those should be useless. +# Another example is tampering with the store from an outside system. +# - 'nodev' to enforce no device files in the store +# Note that we can't use "chown root:nixbld" here # because users/groups might not exist yet. + # Silence chown/chmod to fail gracefully on a readonly filesystem # like squashfs. chown -f 0:30000 /nix/store chmod -f 1775 /nix/store -if [ -n "@readOnlyNixStore@" ]; then - # #375257: Ensure that we pick the "top" (i.e. last) mount so we don't get a false positive for a lower mount. - if ! [[ "$(findmnt --direction backward --first-only --noheadings --output OPTIONS /nix/store)" =~ (^|,)ro(,|$) ]]; then - if [ -z "$container" ]; then - mount --bind /nix/store /nix/store - else - mount --rbind /nix/store /nix/store - fi - mount -o remount,ro,bind /nix/store - fi -fi +missing_opts=() # stores the missing mount options that still need to be applied to the nix store +current_opts="$(findmnt --direction backward --first-only --noheadings --output OPTIONS /nix/store)" +for mount_opt in @nixStoreMountOpts@ ; do + # #375257: Ensure that we pick the "top" (i.e. last) mount so we don't get a false positive for a lower mount. + # matches '$opt', foo,$opt', '$opt,foo', 'foo,$opt,bar' + # crucially, it does not match 'foo$opt', otherwise e.g. 'errors=remount-ro' would yield false positives for 'ro' + if ! [[ "$current_opts" =~ (^|,)"$mount_opt"(,|$) ]]; then + missing_opts+=("$mount_opt") + fi +done + +# only change the mount options if any need changing +if [[ ${#missing_opts[@]} != 0 ]]; then + if [ -z "$container" ]; then + mount --bind /nix/store /nix/store + else + mount --rbind /nix/store /nix/store + fi + + # apply the missing mount options + mount -o remount,"$(IFS=, ; echo "${missing_opts[*]}")",bind /nix/store +fi if [ "${IN_NIXOS_SYSTEMD_STAGE1:-}" != true ]; then # Use /etc/resolv.conf supplied by systemd-nspawn, if applicable. diff --git a/nixos/modules/system/boot/stage-2.nix b/nixos/modules/system/boot/stage-2.nix index d72ea225fb24..9565ac5e8ef1 100644 --- a/nixos/modules/system/boot/stage-2.nix +++ b/nixos/modules/system/boot/stage-2.nix @@ -17,7 +17,8 @@ let replacements = { shell = "${pkgs.bash}/bin/bash"; systemConfig = null; # replaced in ../activation/top-level.nix - inherit (config.boot) readOnlyNixStore systemdExecutable; + inherit (config.boot) systemdExecutable; + nixStoreMountOpts = lib.concatStringsSep " " (map lib.escapeShellArg config.boot.nixStoreMountOpts); inherit (config.system.nixos) distroName; inherit useHostResolvConf; inherit (config.system.build) earlyMountScript; @@ -38,6 +39,16 @@ let in { + imports = [ + (lib.mkRemovedOptionModule + [ + "boot" + "readOnlyNixStore" + ] + "Please use the `boot.nixStoreMountOpts' option to define mount options for the Nix store, including 'ro'" + ) + ]; + options = { boot = { @@ -51,14 +62,20 @@ in ''; }; - readOnlyNixStore = mkOption { - type = types.bool; - default = true; + nixStoreMountOpts = mkOption { + type = types.listOf types.nonEmptyStr; + default = [ + "ro" + "nodev" + "nosuid" + ]; description = '' - If set, NixOS will enforce the immutability of the Nix store - by making {file}`/nix/store` a read-only bind - mount. Nix will automatically make the store writable when - needed. + Defines the mount options used on a bind mount for the {file}`/nix/store`. + This affects the whole system except the nix store daemon, which will undo the bind mount. + + `ro` enforces immutability of the Nix store. + The store daemon should already not put device mappers or suid binaries in the store, + meaning `nosuid` and `nodev` enforce what should already be the case. ''; }; @@ -85,6 +102,5 @@ in config = { system.build.bootStage2 = bootStage2; - }; } diff --git a/nixos/tests/boot-stage2.nix b/nixos/tests/boot-stage2.nix index 90f0ac01207c..657e12116955 100644 --- a/nixos/tests/boot-stage2.nix +++ b/nixos/tests/boot-stage2.nix @@ -10,7 +10,24 @@ import ./make-test-python.nix ( lib, ... }: + let + # Prints the user's UID. Can't just do a shell script + # because setuid is ignored for interpreted programs. + uid = pkgs.writeCBin "uid" '' + #include + #include + int main(void) { + printf("%d\n", geteuid()); + return 0; + } + ''; + in { + users.users.alice = { + isNormalUser = true; + uid = 1000; + }; + virtualisation = { emptyDiskImages = [ 256 ]; @@ -32,6 +49,10 @@ import ./make-test-python.nix ( }; }; + environment.systemPackages = [ pkgs.xxd ]; + + system.extraDependencies = [ uid ]; + boot = { initrd = { # Format the upper Nix store. @@ -50,6 +71,19 @@ import ./make-test-python.nix ( mount -t overlay overlay \ -o lowerdir=/mnt-root/nix/store/ro,upperdir=/mnt-root/nix/store/rw,workdir=/mnt-root/nix/store/work \ /mnt-root/nix/store + + # Be very rude and try to put suid files and/or devices into the store. + evil=/mnt-root/nix/store/evil + mkdir -p $evil/bin $evil/dev + + echo "making evil suid..." >&2 + cp /mnt-root/${builtins.unsafeDiscardStringContext "${uid}"}/bin/uid $evil/bin/suid + chmod 4755 $evil/bin/suid + [ -u $evil/bin/suid ] || exit 1 + + echo "making evil devzero..." >&2 + mknod -m 666 $evil/dev/zero c 1 5 + [ -c $evil/dev/zero ] || exit 1 ''; kernelModules = [ "overlay" ]; @@ -66,6 +100,32 @@ import ./make-test-python.nix ( machine.wait_for_unit("multi-user.target") machine.succeed("test /etc/post-boot-ran") machine.fail("touch /nix/store/should-not-work"); + + for opt in ["ro", "nosuid", "nodev"]: + with subtest(f"testing store mount option: {opt}"): + machine.succeed(f'[[ "$(findmnt --direction backward --first-only --noheadings --output OPTIONS /nix/store)" =~ (^|,){opt}(,|$) ]]') + + # should still be suid + machine.succeed('[ -u /nix/store/evil/bin/suid ]') + # runs as alice and is not root + machine.succeed('[ "$(sudo -u alice /nix/store/evil/bin/suid)" == 1000 ]') + # can be remounted and runs as root + machine.succeed('mount -o remount,suid,bind /nix/store && mount >&2') + machine.succeed('[ "$(sudo -u alice /nix/store/evil/bin/suid)" == 0 ]') + # double checking we can undo it + machine.succeed('mount -o remount,nosuid,bind /nix/store && mount >&2') + machine.succeed('[ "$(sudo -u alice /nix/store/evil/bin/suid)" == 1000 ]') + + # should still be a character device + machine.succeed('[ -c /nix/store/evil/dev/zero ]') + # should not work + machine.fail('[ "$(dd if=/nix/store/evil/dev/zero bs=1 count=1 | xxd -pl1)" == 00 ]') + # can be remounted and works + machine.succeed('mount -o remount,dev,bind /nix/store && mount >&2') + machine.succeed('[ "$(dd if=/nix/store/evil/dev/zero bs=1 count=1 | xxd -pl1)" == 00 ]') + # double checking we can undo it + machine.succeed('mount -o remount,nodev,bind /nix/store && mount >&2') + machine.fail('[ "$(dd if=/nix/store/evil/dev/zero bs=1 count=1 | xxd -pl1)" == 00 ]') ''; meta.maintainers = with pkgs.lib.maintainers; [ numinit ];