The Glob is dead, long live the alias! - AppArmor on NixOS Part 2

This is part 2 of my adventures with AppArmor on NixOS. For part 1, visit AppArmor and apparmor.d on NixOS.

Why matching against the store is difficult

Where a regular system has /bin which is write-protected to regular users, Nix has a weird quirk: Binaries are typically contained in bin folders in /nix/store/*/bin. This poses an interesting challenge for MACs. Given the goal to match hello executable in apparmor, one could naively attempt to match against /nix/store/*/bin/hello. There is two problems with this approach.

Naive fix: Character matching (extremely slow)

Getting around the first issue can be achieved by replacing the /nix/store/*/ glob with character matching, using {[a-zA-Z0-9],.,-,_} repeated a bunch of times to reach the longest potential name of a derivation. That will take hours to just parse one apparmor rule, and apparmor being a process that slows down the OS when booting, this is unacceptable. Clever grouping of the pattern can reduce the load times to about 30 second per profile, but extrapolating to a couple hundred profiles on a system this is still really bad. It does work though! If only there’d be a way to match only the things that are actually relevant instead of literally any possible store entry ever….

Naive fix: Reducing availability to use nix commands (inconvenient and a double-edged sword)

Root user can apply a new NixOS configuration or disable apparmor immediately. Therefore, restricting the things root can put into the store because apparmor might false-match it causing security issues falls within the category of “don’t be stupid”. Restricting access to what non-root users can place into the store can be a viable way to limit access though. This can be achieved with some simple NixOS configuring:

nix.settings.allowed-users = [
  "root"
];

The problem with this: This makes work on anything nix-related extremely frustrating, and also introduces new vulnerabilities as anything you’ll be trying in a nix-shell or on a third-party flake will run under root. Bad idea.

Aliases: A potential good solution

How aliases work in apparmor

Aliases can allow attaching multiple paths to one rule. They are resolved after variables. An alias rule follows the syntax alias /replace -> /actual,, which always feels weird. In practice this means loading an alias alias /bin/hello -> /nix/store/26xbg1ndr7hbcncrlf9nhx5is2b25d13-hello-2.12.1/bin/hello, means any rule or profile matching against /bin/hellowill now also match against the nix store equivalent. Also noteworthy is the fact multiple aliases can target the same thing. In above example, additionally adding alias /bin/hello -> /nix/store/some-other-hash-hello-2.12.1/bin/hello, would mean both of those two instances would match the same rules. But no other hello would match.

How aliases help

This is indirectly useful: If we could construct all the aliases to collect all the different packages programatically, we wouldn’t need globs or character matching! This promises to solve both issues at the same time, with minimal pain.

Aliases just work. There is no globs involved, and they do not accidentally add conflicting rules that might become a problem. Runtime impact of aliasing will be discussed in a bit.

Further, if building aliases only for the currently active generation, store entries added by an unauthorized but potentially malicious or compromised user will never match the apparmor patterns unless nix/store daemon is compromised or a hash collision occurs. Either of those scenarios has significantly bigger impacts than just compromising apparmor.

aa-alias-manager

aa-alias-manager is an attempt at doing just that: it takes a pattern file of things to aggregate, queries the current system, and builds alias files. The pattern definition happens in an easily edited json file. The store lookup is essentially a fancy wrapper around the command nix-store -q /run/current-system -R. This has issues too, like relying on the nix executable to be available and /run/current-system being a viable entry point to querying the current system dependency paths. Needless to say there needs to happen more work to enable things like Home Manager. However, the patterns generated by this alias manager do solve the requirements here.

Integrating it into NixOS took some work (mostly because systemd hates me, or because i am stupid; You pick one). First there obviously needs to be a package. That isn’t too bad, its just your standard rust application with some wrapping to link against nix executable. The systemd service is a bit more interesting:

{
  config,
  lib,
  pkgs,
  ...
}:
let
  inherit (lib) getExe mkIf;
  aa-alias-manager = pkgs.callPackage ./aa-alias-manager-package.nix { };
  alias_dir = "/run/aliases.d";
in
{
  config = mkIf config.security.apparmor.enable {
    security.apparmor.includes."tunables/alias.d/store" = ''
      include if exists "${alias_dir}"
    '';

    systemd.services.aa-alias-setup = {
      after = [ "local-fs.target" ];
      before = [ "apparmor.service" ];
      requiredBy = [ "apparmor.service" ];

      path = [ config.nix.package ]; # respect the users choice to use alternative nix implementations

      unitConfig = {
        Description = "Initialize alias rules required for AppArmor policies";
        DefaultDependencies = "no";
        ConditionSecurity = "apparmor";
      };

      serviceConfig = {
        Type = "oneshot";
        ExecStart = "${getExe aa-alias-manager} -o ${alias_dir} -p ${./aa-alias-patterns.json}";
      };
    };
  };
}

What this does is create a service aa-alias-setup that is required by and executes before apparmor. It places the aliases into a special directory in /run/aliases.d, and registers that path with apparmor to import those aliases. And that’s basically it! This does work. The state of this at the time of writing can be found on my gitea.

This does not even need any patching of the apparmor NixOS module!

Alternative: Flake setup

aa-alias-manager exposes a nixos module with essentially this configuration, and can be added like any nixos module added by flake. Information on exposed settings and installation can be found in the aa-alias-manager README. Cons: It’s flakes. Flakes are weird. Pros: easier updating, as well as making use of VM tests in the aa-alias-manager CI.

Execution time of LOTS of aliases

Aliasing all the dependencies for the current generation does take some time. Generating the aliases is basically instant on my system, taking less than a single second. However, apparmor parsing those aliases does take a bit longer.

I found it is problematic to have many aliases pointing to the same thing: Having around three thousand aliases alias /bin -> /nix/store/.../bin, will slow down the parsing significantly. However, having more aliases with less intersection does work out nicely. Having aliases alias /bin/... -> /nix/store/.../bin/..., has no noticeable impact on apparmor parsing time, even though the alias file grows to multiple MB in size and now contains almost 50 thousand aliases for binaries. This is weird, but easy enough to work around. This means mass-aliasing is a very viable way to match profiles and rules against nix store entries without extensive globbing.

Remaining issues

Aliases and their logic is very limited. Notably, an alias targeting /bin will never match a rule or profile matching against /{s,}bin, even though /bin would match against that pattern. This is a problem: Some more complex programs have rules that make use of this. Notably, the apparmor.d rule for firefox matches against /{bin/firefox{,.sh,-esr,-bin},{,usr/}lib{,exec,32,64}/firefox{,.sh,-esr,-bin}/firefox{,.sh,-esr,-bin},opt/firefox{,.sh,-esr,-bin}/firefox{,.sh,-esr,-bin}} once resolved. This will never match an alias targetting /bin/firefox even though imo it should. I opened an official report over on the apparmor GitLab to hopefully get this fixed or a workaround: https://gitlab.com/apparmor/apparmor/-/issues/455

This also disregards live changes to the nix store that do not restart (and thus, refresh) apparmor. This can be any invocation of the nix command. This means any program added via nix-shell will by default not be caught in any profile of apparmor. This is partly by design, as we wanted to avoid false matching. But now there is a need for some actual matching, and this is a non-trivial task that I will likely never figure out by myself.

Thanks

I also want to thank Hexa and Atemu, as well as the other incredible people at CCCDA and on fedi. I have been quite frustrated at times when everything just refused to work, but the support and input has been amazing. My presentation at our local nix meetup also helped me understand the difficulty trying to contain the nix store in a way that is both flexible enough to make non-nix-specific profiles have a chance to work while being restrictive enough to make sense as a security too. While the alias manager in its current form is only an attempt to address these things, i now see there is more options than i initially considered when starting this journey.