all posts

AppArmor vs SELinux for sandboxing: what mandatory access control actually buys you

Ajay Kumar··10 min read

AppArmor and SELinux are the two mandatory access control systems you are actually likely to meet on a Linux box. Both are Linux Security Modules. Both sit on the syscall path and get to say no after the ordinary uid/gid permission check has already said yes. Both are routinely described, by people who should know better, as things that "sandbox" a workload. They do something narrower and more useful than that, and the difference matters a great deal if what you are holding is code you did not write and have no reason to trust.

This post covers what an LSM actually is and where these two hook in, the honest contrast between AppArmor's path-based model and SELinux's label-based one, how each is really deployed in the wild, what the operational cost of policy looks like in a year-two system, and the part that gets skipped: what MAC structurally cannot do, why that has nothing to do with how well you wrote the policy, and why the right answer for a VMM process is to have a profile AND a boundary rather than to pick one.

What an LSM actually is, and where it sits

The Linux Security Module framework is a set of hooks scattered through the kernel at security-relevant decision points: opening a file, executing a binary, binding a socket, sending a signal, mapping memory, mounting a filesystem. At each of those points the kernel calls out to whatever security modules are registered and asks a yes-or-no question about the operation it is about to perform. That is the whole architecture. The LSM does not implement the operation, does not see the data, and does not intercept the syscall in the way seccomp does. It answers a question the kernel asks on its own way through.

The placement is the important bit. The hook fires after the classic discretionary access control check — the uid, gid, and mode bits that Unix has had since the 1970s. DAC is discretionary because the owner of an object decides who may touch it: chmod is available to you, and root bypasses the whole thing anyway. MAC is non-discretionary because the policy comes from somewhere the process cannot reach. A confined process running as root, holding every capability, cannot chmod its way past a MAC denial and cannot switch the policy off, because turning it off is itself an operation the policy governs.

That gives you the property people actually want from MAC: it survives privilege escalation inside the confinement. If your PDF renderer is exploited and the attacker gets root inside that process, DAC has already lost — root reads everything. MAC has not lost, because the profile said this domain may read exactly these things and root is not an argument the LSM hook accepts. That is a real, load-bearing property and it is the reason both systems exist.

The mental model: DAC asks "does this user own this file?" MAC asks "is this program allowed to be doing this at all?" Both must say yes. Only one of them can be argued with by becoming root.

Two structural notes worth having straight. First, an LSM can only restrict, never grant — if DAC already denied it, no profile makes it possible. MAC is subtractive, which is what makes it safe to layer. Second, historically only one "major" module (SELinux, AppArmor, Smack, TOMOYO) could be active on a given kernel, while small stackable modules like Landlock, Yama, and lockdown could run alongside. Kernel support for stacking has been slowly improving, but running SELinux and AppArmor together on one box is still not a thing anybody deploys. In practice you get the one your distribution picked.

AppArmor: profiles that name paths

AppArmor attaches a profile to an executable. When the kernel execs a binary that has a profile, the resulting process enters that profile's confinement and stays there, and children inherit or transition according to rules you wrote. The profile is a text file that names filesystem paths with the rights permitted on them, plus rules for capabilities, network operations, signals, ptrace, and mount. It reads roughly like an allow-list with globs, and that is exactly what it is.

The virtue of this design is that you can hand a profile to a colleague and they can read it. A path-based profile is self-documenting in a way label policy simply is not: the object is named by the same string you would type into an editor. Writing one is a normal afternoon's work. Reviewing one in a pull request is possible. When it breaks, the log line tells you which path was denied, and the path means something to you. Tooling exists to bootstrap from observed behaviour — run in complain mode, exercise the workload, let the log-parsing helper propose rules — and it produces something a human can then edit down, which is not nothing.

# ILLUSTRATIVE ONLY -- this is a shape, not a drop-in profile. Every path here
# is specific to one deployment, and AppArmor policy syntax moves between major
# versions. Check your own paths and the current apparmor.d(5) before loading.

abi <abi/4.0>,
include <tunables/global>

profile pandastack-vmm /usr/local/bin/firecracker flags=(attach_disconnected) {
  include <abstractions/base>

  # The VMM's actual job: talk to KVM, listen on its own control socket.
  /dev/kvm                                        rw,
  /run/pandastack/vm-*/firecracker.sock           rw,

  # Guest artifacts it may open. The memory file gets mapped, so it needs the
  # lock right too; the snapshot state file is read-only on the restore path.
  /var/lib/pandastack/vms/*/rootfs.ext4           rwk,
  /var/lib/pandastack/vms/*/vm.mem                rwk,
  /var/lib/pandastack/vms/*/vm.state              r,
  /var/lib/pandastack/vms/*/firecracker.log       w,

  # The TAP device is created for it by the agent; the VMM only opens it.
  /dev/net/tun                                    rw,

  # Everything below is the interesting half of the profile: the things this
  # process must never touch even if a bug hands an attacker its uid.
  deny /etc/pandastack/**                         rwx,
  deny /var/lib/pandastack/seeds/**               w,
  deny /root/**                                   rwx,
  deny @{HOME}/**                                 rwx,

  # A VMM that execs a shell is already having a very bad day. Say so.
  deny /bin/*                                     x,
  deny /usr/bin/*                                 x,
  deny /usr/sbin/*                                x,
}

That fragment is illustrative and deliberately boring, which is the point. The top half is what the process legitimately needs. The bottom half — the explicit denials for config, secrets, home directories, and the entire executable surface it should never reach — is where the actual security value sits, because it is the half that still holds when the process is compromised and running as root.

Where path-based policy gets fooled

A path is a name, and a name is not a stable reference to an object. This is the fundamental critique of path-based MAC and it is a fair one. The same file can be reachable under many names via hard links; the same name can be made to resolve to a different file via renames, symlinks, or a bind mount landing somewhere your glob already permits. AppArmor mediates enough of this that the naive attacks do not work — link creation is itself mediated, and modern profiles can control mount operations — but the model is still one where you are reasoning about a namespace an adversary may be able to reshape, rather than about the objects themselves.

For the job AppArmor is normally doing — confining a known daemon that you also wrote or packaged, on a machine where nobody hostile can create mounts — this is a theoretical concern that stays theoretical. For confining genuinely adversarial code that has the ability to manipulate its own mount namespace, it is not theoretical at all, and it is the reason serious multi-tenant systems do not treat a path-based profile as the boundary. There is a deeper version of this argument in /blog/landlock-lsm-explained, where Landlock avoids the whole class by pinning rules to file descriptors rather than to strings.

The other practical gap is coverage. AppArmor confines the binaries that have profiles. Everything else runs unconfined, and "unconfined" means exactly what it says. A profile that is not loaded, or that a package update quietly replaced, is not a weaker control — it is no control, and nothing in the system is going to raise its voice about it.

SELinux: labels, type enforcement, and domain transitions

SELinux does not care what a file is called. Every object in the system — files, directories, sockets, ports, devices, processes — carries a security context, stored for files in an extended attribute and computed for everything else. A context looks like user:role:type:level, and for almost all practical policy work the field that does the work is the type. A process runs in a domain (its type); an object has a type; policy is a large set of rules of the form "a process in domain D may perform these operations on objects of type T of this class." Anything not explicitly allowed is denied. This is type enforcement, and it is the core of SELinux.

Because the label lives on the object, renaming the file changes nothing, hard-linking it changes nothing, and bind-mounting it somewhere else changes nothing. The entire class of attack that path-based policy has to defend against does not arise, because the policy never referred to a name in the first place. This is the honest reason SELinux is the stronger model, and it is not a close call.

The second mechanism worth understanding is the domain transition. When a process execs a binary, policy can specify that the resulting process runs in a different domain — the binary is an entrypoint into that domain, and the transition happens automatically at exec. This is how a service manager running in one domain starts a daemon that lands in a tightly confined one, without either side asking for it. It is also how privilege boundaries survive process spawning, which path-based systems have to model with explicit transition rules on each exec rule.

There is a third layer people forget: multi-category security. On top of type enforcement, SELinux can attach categories to a context, and processes with different category sets cannot touch each other's objects even when their types are identical. This is what makes per-instance separation possible from a single policy — the same daemon type running for two tenants, with two category sets, mutually blind. It is the mechanism behind sVirt, which labels each virtual machine's disk images with a unique category set so one compromised VMM cannot open another VM's disk. That is a genuinely strong control and it is the best argument in this whole post for taking SELinux seriously on a virtualization host.

# --- 1. What context is this thing actually running in? ---
id -Z                                    # my own login context
ps -eZ | grep firecracker                # the VMM's domain -- or unconfined_t
ls -Z /var/lib/pandastack/vms/abc123/    # labels on the guest artifacts
getenforce                               # Enforcing | Permissive | Disabled

# --- 2. Something broke. Find the real denial before theorising about it. ---
ausearch -m AVC,USER_AVC -ts recent
# type=AVC ... denied  { read } for  pid=4711 comm="firecracker"
#   name="rootfs.ext4" scontext=system_u:system_r:svirt_t:s0:c12,c840
#   tcontext=system_u:object_r:default_t:s0 tclass=file permissive=0
#
# Read the two contexts, not the verb. scontext is who you are, tcontext is
# what you touched, and "default_t" on a file you created is almost always a
# labelling mistake rather than a policy gap.

# Policy can be told to deny quietly. If a failure produces no AVC at all,
# turn dontaudit off, reproduce it, then put it back.
semodule -DB     # disable dontaudit rules
# ...reproduce the failure, run ausearch again...
semodule -B      # restore

# --- 3. Triage, in the order that is usually correct. ---
# 3a. Is it just a mislabelled file? This is the common case, and the fix is a
#     relabel, not a policy change. `cp` inherits the destination's label;
#     `mv` drags the source's label along with it, which is how a file ends up
#     in the right directory with the wrong context.
matchpathcon /var/lib/pandastack/vms/abc123/rootfs.ext4
restorecon -Rv /var/lib/pandastack/vms/abc123/

# 3b. Does a boolean already exist for this? Someone usually got here first.
getsebool -a | grep -i virt

# 3c. Is this path simply new? Teach the label mapping instead of the rule.
semanage fcontext -a -t svirt_image_t '/var/lib/pandastack/vms(/.*)?'
restorecon -Rv /var/lib/pandastack/vms

# --- 4. Only now, audit2allow -- and read what it wrote before installing. ---
ausearch -m AVC -ts recent | audit2allow -R           # print, do not install
ausearch -m AVC -ts recent | audit2allow -M ps-vmm    # writes ps-vmm.te + .pp
cat ps-vmm.te                                         # <-- the skipped step
semodule -i ps-vmm.pp

# audit2allow writes a rule for every denial it is shown, including the ones
# caused by a real attack and the ones caused by the mislabelling in step 3a.
# Piping a whole audit log into it produces a module that allows the incident.

# --- 5. Debug one domain, not the whole machine. ---
semanage permissive -a svirt_t     # this domain logs instead of blocking
semanage permissive -d svirt_t     # ...and back to enforcing when you are done

The audit2allow problem

The traditional SELinux workflow, as practised: set permissive, break production, run audit2allow, hope. The tool has a reputation and it is only half deserved. audit2allow does precisely what it says — it reads denial records and emits policy that would have allowed them. It is a transcription tool, not an analysis tool, and it has no idea whether the denial it is transcribing came from a legitimate operation, from a file that ended up with the wrong label because somebody used mv instead of cp, or from the attacker whose activity is the reason you are reading the audit log at all.

The failure mode is mechanical: pipe a broad slice of audit log into audit2allow, install the module, and you have permanently widened the policy to cover an incident. The discipline is equally mechanical, and it is in the script above — check the labels first, check for an existing boolean second, teach the file-context mapping third, and only write a rule when none of those is the actual answer. Then read the generated .te file before installing it, because that file is the policy change and nobody reviews what they did not print.

The other trap is silence. Policy can carry dontaudit rules that suppress denial records for operations that are expected to fail noisily and harmlessly. When a failure produces no AVC at all, the answer is usually that a dontaudit rule ate it, not that SELinux is innocent — hence the semodule -DB dance. And when you need breathing room, mark the single domain permissive rather than the whole machine. A permissive domain still logs everything; a permissive machine logs everything and protects nothing.

How each is really deployed

AppArmor is the Ubuntu and Debian answer, and its deployment philosophy is targeted hardening of known daemons. The distribution ships profiles for the things most likely to be exposed — web servers, DNS, print, container tooling, the browser sandbox helpers — and everything else runs unconfined. It is a pragmatic bet: confine what faces the network, do not attempt to model the whole system, and keep the policy legible enough that maintainers will actually maintain it. Recent Ubuntu releases also use AppArmor to restrict who may create unprivileged user namespaces, which is a nice example of MAC being used to close off a kernel feature rather than a file path.

SELinux is the RHEL, Fedora, CentOS Stream, and Android answer. The default RHEL-family policy is also called targeted, and the name means the same thing: system daemons run in confined domains, ordinary user sessions run in a largely unconfined domain, and the policy's ambition is scoped to the services rather than to everything. Android is the interesting outlier — it runs SELinux in full enforcing mode with a policy that confines essentially everything, which is possible because the platform controls the entire userspace and can afford to write policy for all of it. Nobody has that luxury on a general-purpose server.

Container runtimes use both, and it is worth being precise about what they get. On an AppArmor system the runtime applies a default profile to every container, blocking a set of obviously dangerous operations on host paths. On an SELinux system the runtime runs containers in a dedicated container domain and — this is the valuable part — assigns each container a unique MCS category set so containers cannot read each other's volumes even when the type is the same. Both are real improvements over nothing. Neither changes the fact that the container is calling into your kernel; see /blog/why-docker-is-not-a-sandbox for the long version of that argument.

What MAC does not buy you

Here is the part that earns the post. A MAC policy constrains what a process is permitted to do. It does not change who is doing it. Every syscall the confined process makes still enters your host kernel and is still serviced by your host kernel's implementation of that call. The LSM hook fires somewhere along the way and may return a denial — but the hook is in the same kernel, on the same code path, in the same address space as the bug you are worried about.

So if there is a memory-corruption bug in the implementation of a syscall your policy permits, MAC did not remove it. It narrowed the set of reachable paths, sometimes substantially, and that is worth real money in expectation. But narrowing a surface is not the same operation as replacing it. And once a kernel exploit lands, the enforcer is part of what was compromised: a kernel that has been convinced to write to arbitrary memory is not going to be stopped by its own LSM hooks on the way out. This is the same structural point as the one in /blog/seccomp-explained, arriving from a different direction — seccomp reduces which doors exist, MAC reduces what is behind each door, and both are inside the building.

The layers are orthogonal, which is why the argument is never "which one" and always "which ones." Here is the honest ledger:

  • AppArmor — Model: per-executable profiles naming filesystem paths, plus capability, network, signal, and mount rules; confinement attaches at exec. Strength: legible policy a team will actually maintain and review, quick to author, good default profiles from the distribution, real protection against a compromised daemon that gained root. Cost: paths are names, and names can be reshaped by mounts and links in adversarial setups; unprofiled binaries are simply unconfined; profiles rot when a package moves its files.
  • SELinux — Model: every object carries a label; type enforcement rules allow specific operations between domains and types; domain transitions on exec; MCS categories for per-instance separation. Strength: the strongest general-purpose MAC on Linux — immune to rename and link games because policy never names a path, and MCS gives per-tenant separation from one policy (this is how sVirt separates VM disk images). Cost: a policy language and a mental model that take real time to acquire, mislabelled files that look exactly like policy bugs, dontaudit rules hiding evidence, and a triage workflow whose most popular tool will happily codify an attack into permanent policy.
  • seccomp — Model: a BPF filter attached to a process that inspects the syscall number and shallow scalar arguments and returns allow, errno, kill, or trap. Strength: directly removes syscalls from reachability, which is the cleanest available way to shrink kernel attack surface; cheap, unprivileged, inherited across fork and exec, irrevocable. Cost: cannot dereference pointers so it cannot filter on paths or strings, tightening it breaks workloads in ways that are hard to diagnose, and it still leaves you sharing a kernel.
  • Landlock — Model: an unprivileged stackable LSM where the process itself builds a ruleset over filesystem hierarchies pinned by file descriptor and applies it to itself, permanently. Strength: no root, no sysadmin, no policy file — an application can confine its own risky subsystem at runtime, and file-descriptor-pinned rules sidestep the TOCTOU races path-based policy has to defend against. Cost: filesystem scope plus a narrow slice of network, ABI capabilities vary by kernel version so you must probe at runtime, and it is not available in every guest kernel.
  • MicroVM — Model: the workload runs against its own guest kernel on virtual hardware; the only route toward the host is a CPU-enforced VM exit into a small device-emulation surface in a VMM process. Strength: it changes the boundary rather than the permissions — a kernel bug the workload triggers is a bug in a kernel that belongs to that one guest, which you were going to delete anyway. Cost: a VM per workload, memory that is genuinely committed, an extra kernel to keep current, and a device surface plus VMM that now need their own hardening — including seccomp, a jailer, and, yes, an LSM profile.
MAC narrows what a process may reach. It does not change whose kernel is reached. Those are different sentences, and only one of them is a boundary.

The full stack ranking, from a chroot upward, is in /blog/code-isolation-hierarchy. The adjacent layers have their own posts: syscall filtering in /blog/seccomp-explained, visibility in /blog/user-namespaces-explained-for-sandboxing, resource ceilings in /blog/cgroups-v2-explained-for-sandboxing, and unprivileged self-confinement in /blog/landlock-lsm-explained.

Confining a VMM: both, not either

The case that makes the "both" argument concrete is the one we run in production. A microVM boundary moves the kernel the workload talks to, but it introduces a new host-side process — the VMM — that holds /dev/kvm, maps guest memory, and owns a TAP device. That process is the thing an escaping guest lands in. Its attack surface is small by design, but small is not zero, and it is running on the host.

Firecracker's own answer is two layers deep before you add anything: a jailer that chroots the VMM, drops it to an unprivileged uid, puts it in a cgroup and its own namespaces, and a seccomp filter the VMM installs on itself so that a compromised VMM can barely make a syscall. Details of both are in /blog/firecracker-jailer-explained and /blog/firecracker-seccomp-bpf-filter-explained. Adding an LSM profile on top is not redundancy theatre — it covers a different axis. seccomp says which syscalls; the profile says which objects. A VMM that legitimately calls openat is still a VMM that should never open another tenant's snapshot, and only one of those two mechanisms can express that.

# The four layers under one microVM, from outermost policy to the real boundary.

# LAYER 1 -- MAC on the VMM process itself (host-side, administrator-owned).
aa-status | grep -A2 firecracker         # AppArmor: loaded? enforce or complain?
# ...or, on an SELinux host, the equivalent question:
ps -eZ | grep firecracker                # a confined domain, or unconfined_t?

# LAYER 2 -- jailer: drop uid/gid, chroot, cgroup, its own net + pid namespace.
jailer --id "$VM_ID" \
  --exec-file /usr/local/bin/firecracker \
  --uid 30000 --gid 30000 \
  --chroot-base-dir /srv/jail \
  --netns "/var/run/netns/ns-$VM_ID" \
  -- --config-file vm.json

# LAYER 3 -- seccomp: Firecracker installs a filter on itself at startup.
# Seccomp: 2 is SECCOMP_MODE_FILTER. A 0 here means you are not running what
# you think you are running, and nobody finds that out on a good day.
pid=$(pgrep -f "firecracker.*$VM_ID")
grep -E '^(Uid|Gid|NoNewPrivs|Seccomp):' /proc/"$pid"/status

# LAYER 4 -- the guest kernel, which is the only one of the four that is a
# boundary. The workload's syscalls are serviced inside the VM and never reach
# this kernel at all. Layers 1-3 exist for the case where something gets OUT of
# the VM and into the VMM process; they are the backstop, not the wall.

On an SELinux host the strongest version of this is the sVirt pattern: label each guest's artifacts with a unique category set so the VMM for one guest cannot open another guest's disk or memory file even though both processes run in the same domain. That is per-tenant separation enforced by the kernel, from policy you did not have to write per tenant. If you are building a virtualization host on the RHEL family, this is the feature to reach for, and it is a better use of your SELinux budget than confining anything else on the box.

When to write a profile, and when to reach for a VM

Write a profile when you know the program. The shape that pays off is a long-lived component whose legitimate behaviour is small, stable, and knowable: a daemon you package, a build agent, a VMM, an ingest worker, anything that will run the same way for months. You get a control that survives the process being exploited and becoming root, expressed once and enforced forever, at approximately zero runtime cost. That is an excellent trade and the reason distributions ship policy.

Reach for a VM when you do not know the program. Untrusted code — a tenant's script, a model's freshly generated Python, a package's install hook — has no stable behaviour to model, so there is no profile to write that is both tight enough to matter and loose enough to work. Worse, the threat is not "this program reads a file it shouldn't," it is "this program probes your kernel for a bug," and that is the one thing policy does not address. If you are here for that specific problem, /blog/jailing-llm-generated-code and /blog/how-to-sandbox-untrusted-code go straight at it.

Be honest about the operating cost too, because it is where MAC projects actually die. Policy is code with no tests, written against an environment that changes underneath it. A package update moves a binary and the profile that named the old path silently covers nothing. A new feature opens a new file and the service fails at 3am with an error that does not mention the LSM. Somebody sets the machine permissive during an incident and it stays permissive for a year, because nothing breaks when you do that and nothing celebrates when you undo it. If you adopt MAC, adopt the operational half too: profiles in version control, denial rates on a dashboard, an alert on enforcement being disabled, and negative tests in CI that assert the confinement still denies what you believe it denies. A profile nobody verifies is a belief, not a control.

Permissive mode is a debugging tool with a half-life measured in quarters. If your policy is only enforcing on the machines nobody has had an incident on yet, you do not have a policy — you have a logging configuration.

The reason we default to the boundary for untrusted work is economics rather than purity. If a fresh VM cost thirty seconds, you would pool VMs, share them across tenants, and end up asking a policy language to be your isolation. On PandaStack every create restores a baked Firecracker snapshot rather than cold-booting — roughly 49ms for the restore step, 179ms p50 end to end, around 203ms p99, with the ~3s cold boot paid once at bake time. Forks land in 400-750ms same-host. When a private kernel costs a fifth of a second, "write a very good profile instead" stops being the cheaper option.

Where this leaves you

AppArmor and SELinux are answering the same question with different nouns. AppArmor names paths, which makes its policy legible and maintainable and leaves it reasoning about a namespace an adversary might reshape. SELinux labels objects, which makes it structurally stronger and considerably more expensive to learn, operate, and debug — and gives you MCS categories, which is the single best MAC feature on a multi-tenant host and the one most teams never turn on. If your distribution picked one, use that one properly rather than fighting about the other.

Then be precise about what you bought. You bought a second, non-discretionary gate that a compromised process cannot open by becoming root, and a narrowed set of objects any given program can reach. You did not buy a different kernel. Every permitted syscall still executes host kernel code, and the hook that would deny you lives in the same kernel as the bug you are worried about. MAC composes beautifully with seccomp, namespaces, cgroups, and Landlock — five controls, one kernel, real defence in depth — and the depth is all on one side of a wall you have not moved.

For code you did not write and cannot model, move the wall: a guest kernel per workload, with the VMM on the host confined by a jailer, a seccomp filter, and an LSM profile of its own. Not because the profile is weak, but because it is answering a different question than the one untrusted code asks. The layered version is in /blog/code-isolation-hierarchy, and if you want the failure mode this is all guarding against, /blog/vm-escape-attacks-explained is the uncomfortable read.

Frequently asked questions

What is the actual difference between AppArmor and SELinux?

They differ in what the policy refers to. AppArmor is path-based: a profile is attached to an executable and names filesystem paths with the rights permitted on them, so the policy reads like an allow-list you could hand to a colleague. SELinux is label-based: every object in the system carries a security context stored in an extended attribute, and policy consists of type enforcement rules saying which domains may perform which operations on which types, with automatic domain transitions at exec. The consequence is that SELinux is immune to a whole class of attack that AppArmor has to actively defend against — renaming a file, hard-linking it, or bind-mounting it elsewhere cannot change its label, whereas all three change the path a rule would have matched. SELinux also has multi-category security, which lets you separate instances of the same service from a single policy, and there is no AppArmor equivalent. The trade is cost: AppArmor policy is something a team will realistically write and maintain, and SELinux policy is a skill you acquire deliberately. In practice you get whichever one your distribution shipped, and the productive move is to use it properly rather than to migrate.

Is AppArmor or SELinux enough to sandbox untrusted or AI-generated code?

No, and the reason is structural rather than a matter of policy quality. MAC constrains what a process is permitted to do; it does not change which kernel services what the process does. Every syscall the confined process makes still executes your host kernel's implementation of that syscall, and the LSM hook that might deny the operation lives inside that same kernel. So a memory-corruption bug reachable through any syscall your policy permits is still reachable, and a successful kernel exploit compromises the enforcer along with everything else. MAC genuinely narrows the reachable surface, which lowers the odds and is worth having, but narrowing a surface and replacing it are different operations. There is a second practical problem: untrusted code has no stable behaviour to model, so there is no profile that is simultaneously tight enough to be meaningful and loose enough to let arbitrary workloads run. For code you did not write, the control that matches the threat is a boundary that gives the workload its own kernel — a KVM-backed microVM — with MAC, seccomp, and cgroups applied as layers rather than as the answer.

Why does audit2allow have such a bad reputation?

Because it is a transcription tool that people use as an analysis tool. audit2allow reads SELinux denial records and emits policy that would have allowed them, faithfully and without judgement. It cannot tell whether a denial came from a legitimate operation the policy did not anticipate, from a file that ended up with the wrong label because somebody used mv instead of cp, or from an attacker whose activity is the reason you are reading the audit log in the first place. Pipe a broad slice of log into it, install the module, and you have permanently widened your policy to cover whatever was in that log. The discipline that fixes this is ordering: check whether the file is simply mislabelled and fix it with restorecon, check whether an existing boolean already covers the case, teach the file-context mapping with semanage fcontext if the path is new, and only write a rule when none of those is the real answer. Then print the generated .te file and read it before installing, because that file is the policy change and nobody reviews what they never printed. Also remember dontaudit rules can suppress the very denial you are hunting, so a failure with no AVC at all often means the record was hidden, not that SELinux is innocent.

Should I write an AppArmor or SELinux profile for a VMM process like Firecracker?

Yes, and it composes with rather than replaces the hardening the VMM already has. Firecracker is normally run under its jailer, which chroots it, drops it to an unprivileged uid and gid, places it in a cgroup and its own namespaces, and it installs a seccomp filter on itself so a compromised VMM can barely issue a syscall. An LSM profile covers a different axis from all of that: seccomp constrains which syscalls may be made, while the profile constrains which objects may be touched. A VMM that legitimately calls openat should still never be able to open another tenant's snapshot, and only the LSM can express that. On an SELinux host the strongest form is the sVirt pattern — give each guest's artifacts a unique MCS category set so the VMM for one guest cannot open another guest's disk or memory file even though both processes run in the same domain. That is per-tenant separation enforced by the kernel without per-tenant policy, and on a virtualization host it is the highest-value thing you can do with your SELinux budget.

Can root disable SELinux or AppArmor from inside a confined process?

No, and that is the property that makes MAC worth having. Discretionary access control is bypassed by root by design, so once an attacker has root in a process, DAC has stopped being a control. Mandatory access control is not discretionary: the policy comes from outside the process, and modifying or unloading it is itself an operation the policy governs, gated behind a specific capability that a well-written confinement does not grant. A confined process holding every capability still cannot relabel itself into a more permissive domain, cannot load a new policy module, and cannot switch enforcement off. The caveat is about scope. MAC protects you from a compromised confined process, not from a full root compromise arriving from outside that confinement — an unconfined administrative context on the host can set the machine permissive or unload profiles, and anything with kernel-level code execution has already won because the enforcer is part of the kernel it just took over. Confinement is a wall around a process, not a lock on the building.

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.