Skip to content
Go back

AppArmor vs SELinux: Mandatory Access Control Without the Existential Dread

By SumGuy 9 min read
AppArmor vs SELinux: Mandatory Access Control Without the Existential Dread

The Firewall Is On, But Your Processes Are Still Running Wild

You’ve got UFW configured, your ports are locked down, fail2ban is banning bots. But here’s the thing: none of that helps if a process running on your server gets exploited and decides to do something it shouldn’t. A compromised nginx serving a public website should not be able to read /etc/shadow. It should not be able to write to /home. It should not be able to launch other processes. Under standard Linux permissions, if nginx is running as www-data and something exploits it, that attacker has everything www-data can reach.

Mandatory Access Control (MAC) is the layer that enforces what processes can do, regardless of who they’re running as. It runs in the kernel and applies policies that even root processes must follow (when configured correctly). On Linux, this is implemented via the Linux Security Modules (LSM) framework, and the two dominant implementations are AppArmor and SELinux.

They accomplish the same goal through fundamentally different philosophies, which is why people have Strong Opinions about each.


Discretionary vs. Mandatory Access Control

Quick terminology check:

DAC (Discretionary Access Control): The classic Unix permission model — owner, group, others; read/write/execute. You decide what permissions your files have. An attacker who gains your process’s privileges gains your process’s DAC permissions.

MAC (Mandatory Access Control): An additional policy layer enforced by the kernel, independent of file ownership. Even if a process has legitimate credentials, it can only do what the policy explicitly allows. Policy violations are denied (and logged) regardless of user or file permissions.

MAC doesn’t replace DAC — it adds a second, independent checkpoint. Both must pass.


AppArmor: Path-Based and Approachable

AppArmor is the default LSM on Ubuntu and Debian. It confines programs based on paths — you write a profile that says “this program can read these paths, write to those paths, execute these binaries, use these network capabilities.”

AppArmor profiles live in /etc/apparmor.d/ and are loaded into the kernel on boot.

Two Modes

Enforce mode: Violations are blocked and logged. This is what you want in production. Complain mode: Violations are logged but allowed. Use this to develop and refine profiles without breaking things.

Terminal window
# Check AppArmor status
aa-status
# Check a specific profile's mode
aa-status | grep nginx
# Put a profile in complain mode
aa-complain /etc/apparmor.d/usr.sbin.nginx
# Put it back to enforce
aa-enforce /etc/apparmor.d/usr.sbin.nginx

Reading an AppArmor Profile

Here’s a simplified nginx profile:

/etc/apparmor.d/usr.sbin.nginx
#include <tunables/global>
/usr/sbin/nginx {
# Include common abstractions
#include <abstractions/base>
#include <abstractions/nameservice>
# Capabilities nginx needs
capability net_bind_service,
capability setuid,
capability setgid,
# Read nginx configs
/etc/nginx/** r,
/etc/nginx/ssl/** r,
# Read web content
/var/www/** r,
# Write to log files
/var/log/nginx/ rw,
/var/log/nginx/** rw,
# Write to temp files
/tmp/nginx-* rw,
/run/nginx.pid rw,
# Network
network tcp,
network udp,
# Deny everything else implicitly
}

The profile is a whitelist. Anything not explicitly allowed is denied. If nginx tries to read /etc/shadow, AppArmor blocks it.

aa-genprof: Profile Generation the Easy Way

Writing profiles by hand is tedious. aa-genprof generates a starting profile by watching a program run:

Terminal window
# Install tools
apt install apparmor-utils
# Start profile generation for a program
aa-genprof /usr/bin/myapp
# In another terminal: run the application through its normal operations
# Back in aa-genprof: press "S" to scan for events
# aa-genprof will prompt you to allow/deny each access attempt
# Press "F" when done to save the profile

This gives you a working profile based on what the program actually does. You can then review and tighten it.

aa-logprof: Updating Profiles from Logs

When a confined program tries to do something the profile doesn’t allow:

Terminal window
# See AppArmor denials
journalctl -k | grep apparmor | grep DENIED
# Update profiles based on recent violations
aa-logprof
# Walks you through each denial, prompting to allow or deny

Docker and AppArmor

Docker ships with a default AppArmor profile (docker-default) applied to all containers. You can specify profiles:

Terminal window
# Run container with specific profile
docker run --security-opt apparmor=my-custom-profile myimage
# Run container without AppArmor (dangerous, sometimes necessary)
docker run --security-opt apparmor=unconfined myimage

Check if Docker’s default profile is loaded:

Terminal window
aa-status | grep docker

SELinux: Labels on Everything, Trust Nothing

SELinux is the default LSM on RHEL, CentOS, Fedora, and AlmaLinux. It was developed by the NSA (yes, that one) and contributed to the kernel in 2003. It is more powerful than AppArmor, more complex, and responsible for more confused rage-quitting than almost any Linux technology.

The key difference from AppArmor: SELinux uses labels, not paths. Every file, process, port, and socket gets a security context (a label). Policy rules say “processes with label X can access files with label Y.” It doesn’t matter where the file lives — what matters is what label it has.

SELinux Labels

Terminal window
# See file labels
ls -Z /etc/nginx/nginx.conf
# -rw-r--r--. root root system_u:object_r:httpd_config_t:s0 /etc/nginx/nginx.conf
# See process labels
ps auxZ | grep nginx
# system_u:system_r:httpd_t:s0 root nginx ...
# See port labels
semanage port -l | grep http
# http_port_t tcp 80, 443, 488, 8008, 8009, 8443

The label format is: user:role:type:level. The type field is what most policy rules reference.

SELinux Modes

Terminal window
# Check current mode
getenforce
# Enforcing, Permissive, or Disabled
# Temporarily set permissive (survives until next boot)
setenforce 0
# ^ This is the "fix" that security teams weep over
# Permanently set mode
# /etc/selinux/config
SELINUX=enforcing # Enforcing, Permissive, or Disabled

setenforce 0 is like turning off your burglar alarm because it keeps going off. It fixes the symptom (the denial) while ignoring the cause (misconfigured policy). Please don’t do this in production.

Type Enforcement: How SELinux Thinks

The most important SELinux concept is type enforcement. Rules look like:

allow httpd_t httpd_config_t:file { read open getattr };

Translation: “A process of type httpd_t (Apache/nginx) is allowed to read, open, and getattr files with type httpd_config_t.”

You don’t write these rules manually. The policy modules that come with your distribution define thousands of them. Your job is to understand when violations happen and why.

audit2allow: The Escape Hatch

When SELinux blocks something, it logs a denial to the audit log:

Terminal window
# See denials
ausearch -m AVC -ts recent
# or
journalctl -t setroubleshootd
# Generate an allow rule from denials
ausearch -m AVC -ts recent | audit2allow -m mypolicy
# Generate and install a module
ausearch -m AVC -ts recent | audit2allow -M mypolicy
semodule -i mypolicy.pp

audit2allow reads the audit log and writes the minimum policy rules needed to allow the denied operations. It’s powerful but requires judgment — don’t just feed it every denial without understanding what you’re permitting.

Boolean Switches

SELinux policies include boolean switches for common configuration scenarios:

Terminal window
# List all booleans
getsebool -a
# List booleans related to httpd
getsebool -a | grep httpd
# Allow nginx to make network connections
setsebool -P httpd_can_network_connect 1
# Allow nginx to serve files from home directories
setsebool -P httpd_enable_homedirs 1

The -P flag makes the boolean persistent across reboots. Many “SELinux is blocking my app” problems are solved by the right boolean — check these before reaching for setenforce 0.

Relabeling Files

SELinux file labels are stored as extended attributes. If you move files from an unlabeled filesystem or create files in the wrong location, they might get wrong labels:

Terminal window
# Relabel a specific file
restorecon -v /var/www/html/myfile.html
# Relabel a directory recursively
restorecon -Rv /var/www/html/
# See what the label should be for a path
matchpathcon /var/www/html/

Practical Example: Confining a Web App

Let’s say you have a Python Flask app running on port 8080, serving files from /opt/myapp.

With AppArmor

Terminal window
# Run app in complain mode initially
# Start with aa-genprof or create a profile:
cat > /etc/apparmor.d/opt.myapp.myapp << "EOF"
#include <tunables/global>
/opt/myapp/myapp {
#include <abstractions/base>
#include <abstractions/python>
# App files
/opt/myapp/** r,
/opt/myapp/logs/** rw,
# Config
/etc/myapp/ r,
/etc/myapp/** r,
# Network
network tcp,
# Deny everything else
deny /etc/** w,
deny /home/** rw,
deny /root/** rw,
}
EOF
apparmor_parser -r /etc/apparmor.d/opt.myapp.myapp

With SELinux

Terminal window
# Create a custom type for the app
# First, check what's being denied after running in permissive
semanage fcontext -a -t httpd_sys_content_t "/opt/myapp(/.*)?"
restorecon -Rv /opt/myapp
# Allow the app to bind to port 8080
semanage port -a -t http_port_t -p tcp 8080
# If you need custom rules, generate them from audit log
ausearch -m AVC -ts recent | audit2allow -M myapp
semodule -i myapp.pp

AppArmor vs SELinux: The Honest Comparison

FeatureAppArmorSELinux
Default onUbuntu, Debian, SUSERHEL, Fedora, AlmaLinux
Policy modelPath-basedLabel-based
Learning curveModerateSteep
ExpressivenessGoodExcellent
Multi-category securityNoYes
Tool qualityGood (aa-genprof)Good (audit2allow)
Docker integrationFirst-classGood
When confusedCheck path rulesCheck labels + booleans

When to Just Leave It Alone

There’s a third option nobody advertises: leave the default policies in place, don’t touch anything, and let the distribution-maintained profiles do their job. Most major services (nginx, Apache, MySQL, Docker) ship with profiles maintained by their distribution.

The value is in the defaults. AppArmor on Ubuntu is already doing something useful even if you never configure it. SELinux on RHEL is already confining your services. The power users — the ones who write custom profiles for custom apps — are getting more out of it, but they’ve also accepted the complexity cost.

setenforce 0 is always wrong in production. Leaving SELinux in permissive mode “temporarily” and forgetting about it is also very wrong. If it’s causing problems, investigate the denials — they’re telling you something is misconfigured, either in SELinux or in your application’s file paths and labels.

MAC isn’t optional anymore if you’re running anything serious. The question is which flavor of mandatory you prefer: the path-based approachability of AppArmor, or the label-based power of SELinux. Both beat hoping your processes behave themselves.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it may appear here.


Previous Post
tcpdump Basics: Capture Traffic Without Wireshark
Next Post
Your Server Doesn't Know What Random Means (And That's a Problem)

Related Posts