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.
- First, rules for executing child processes can never conflict. This
is generally fine when making a simple profile. However, some programs
like suid wrappers (e.g.
sudo) have the ability to spawn arbitrary programs (/nix/store/*/bin/* Ux,) and more specific rules (/nix/store/*/bin/unix_chkpwd rPx,). On a regular Linux install,/bin/unix_chkpwdwould take priority because it does not contain a glob. However, we are globbing in both paths, which means this would immediately throw a parsing error. - Second, security implications of globbing the whole store are bad.
An unauthorized user can use
nix-shellornix-build(or some other nix command) to place some derivation into the store. That derivation can have a crafted binary that is simply called something, but should not actually fall within the profile that’d match that executable if the pattern is just/nix/store/*/bin/something. Obviously unrestricted processes are even worse than false matches. However, false matches essentially make default profiles with restrictions to some critical paths like~/.sshimpossible. Enabling default profiles instead of leaving anything unmatched just do whatever should also be a goal of apparmor support on NixOS.
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.