SELinux & AppArmor

Why This Matters

Imagine your web server gets compromised through a vulnerability in your application code. The attacker gets a shell running as the www-data user. With traditional Linux permissions (DAC -- Discretionary Access Control), that attacker can read anything www-data can read: configuration files, other users' data, maybe even credentials stored in environment variables. They can open network connections to exfiltrate data. They can write to any directory www-data has write access to.

Traditional permissions answer the question "Is this user allowed to do this?" But they cannot answer "Should a web server process be reading /etc/passwd?" or "Should Apache be making outbound connections to port 4444?"

That is what Mandatory Access Control (MAC) systems like SELinux and AppArmor do. They confine processes to only what they are supposed to do, regardless of what the user permissions would allow. They are your last line of defense when everything else has failed, and they have prevented countless real-world breaches.


Try This Right Now

Check which MAC system your distribution uses:

# Check for SELinux
getenforce 2>/dev/null && echo "SELinux is available"

# Check for AppArmor
sudo aa-status 2>/dev/null && echo "AppArmor is available"

# Which one is active?
cat /sys/kernel/security/lsm

Typical results:

  • RHEL/Fedora/CentOS/Rocky/Alma: SELinux
  • Ubuntu/Debian/SUSE: AppArmor
  • Both are compiled into most modern kernels, but only one is active at a time.

MAC vs DAC

DAC (Discretionary Access Control) -- What You Already Know

DAC is the traditional Unix permission model: the owner of a file decides who can access it. This is the rwxr-xr-x permissions, users, and groups system from Chapter 6.

  DAC Decision:
  +-------------------+
  | Process runs as   |
  | user "www-data"   |
  +-------------------+
           |
           v
  +-------------------+
  | File owned by     |
  | root:root         |  --> Does "www-data" have read permission?
  | Mode: -rw-r--r--  |  --> Yes (world-readable) --> ACCESS GRANTED
  +-------------------+

The problem: DAC is permissive by default. The process gets all the permissions of its user, even if it should not need them.

MAC (Mandatory Access Control) -- The Extra Layer

MAC enforces policies defined by the system administrator, not by file owners. Even if DAC would allow access, MAC can deny it.

  Access Request: Apache (httpd_t) wants to read /etc/shadow

  +----------+     +---------+     +----------+
  | DAC      | --> | Allowed | --> | MAC      | --> DENIED
  | Check    |     | (r--r-- |     | Check    |     (httpd_t is not
  |          |     |  group) |     | (SELinux)|      allowed to read
  +----------+     +---------+     +----------+      shadow_t files)

MAC runs after DAC. Both must allow the access for it to succeed. This means MAC can only restrict further -- it cannot grant permissions that DAC denied.

  +---------------------------------------------------+
  |                                                   |
  |  DAC says YES  +  MAC says YES  =  ACCESS GRANTED |
  |  DAC says YES  +  MAC says NO   =  ACCESS DENIED  |
  |  DAC says NO   +  MAC says ???  =  ACCESS DENIED  |
  |  (DAC check happens first; if it fails, MAC       |
  |   is never consulted)                             |
  |                                                   |
  +---------------------------------------------------+

Think About It: If a process runs as root, DAC almost never denies access. Why does this make MAC even more important for processes that run as root?


SELinux

SELinux (Security-Enhanced Linux) was developed by the NSA and Red Hat. It uses labels (called security contexts) on everything -- files, processes, ports, and users. Policy rules define which labeled processes can access which labeled objects.

SELinux Modes

# Check the current mode
getenforce

Three modes:

ModeBehavior
EnforcingPolicies are enforced. Violations are denied and logged.
PermissivePolicies are NOT enforced, but violations are logged. Useful for debugging.
DisabledSELinux is completely off. Not recommended.
# Switch between modes temporarily (until reboot)
sudo setenforce 1    # Enforcing
sudo setenforce 0    # Permissive

# Check persistent configuration
cat /etc/selinux/config
# /etc/selinux/config
SELINUX=enforcing
SELINUXTYPE=targeted

To change the mode persistently, edit /etc/selinux/config and reboot.

WARNING: Never set SELINUX=disabled in production. If you need to debug, use Permissive mode instead. Disabling and re-enabling SELinux requires a full filesystem relabel, which can take a very long time on large systems.

SELinux Contexts (Labels)

Everything in SELinux has a context with four parts:

  user:role:type:level

  Example:
  system_u:system_r:httpd_t:s0

  user    = system_u   (SELinux user, not Linux user)
  role    = system_r   (role, determines what types are allowed)
  type    = httpd_t    (the TYPE -- most important for policy decisions)
  level   = s0         (MLS level, usually s0 on targeted policy)

The type is the most important part. Almost all SELinux policy decisions on a targeted policy are based on the type.

Viewing Contexts

# See file contexts
ls -Z /var/www/html/
unconfined_u:object_r:httpd_sys_content_t:s0 index.html
# See process contexts
ps -eZ | grep httpd
system_u:system_r:httpd_t:s0     1234 ?  00:00:05 httpd
# See your own context
id -Z

# See port contexts
sudo semanage port -l | grep http
http_port_t          tcp      80, 81, 443, 488, 8008, 8009, 8443, 9000

How SELinux Policy Works

The targeted policy (default on RHEL/Fedora) confines specific services while leaving most user processes unconfined.

  Apache process (httpd_t) wants to read a file:

  +--------------------------------------------+
  | File has type httpd_sys_content_t?         |
  |   YES --> Policy allows httpd_t to read    |
  |           httpd_sys_content_t --> ALLOWED   |
  +--------------------------------------------+

  +--------------------------------------------+
  | File has type user_home_t?                 |
  |   YES --> Policy does NOT allow httpd_t    |
  |           to read user_home_t --> DENIED    |
  +--------------------------------------------+

Changing File Contexts with chcon

chcon changes the context of a file temporarily (it does not survive a relabel):

# Change a file's type to httpd_sys_content_t
sudo chcon -t httpd_sys_content_t /var/www/custom/index.html

# Change recursively
sudo chcon -R -t httpd_sys_content_t /var/www/custom/

# Verify the change
ls -Z /var/www/custom/index.html

Restoring Default Contexts with restorecon

restorecon sets file contexts back to the system default (based on file path):

# Restore default context for a file
sudo restorecon -v /var/www/html/index.html

# Restore recursively
sudo restorecon -Rv /var/www/html/

# The -v flag shows what changed
Relabeled /var/www/html/index.html from unconfined_u:object_r:default_t:s0
to unconfined_u:object_r:httpd_sys_content_t:s0

Setting Permanent Default Contexts with semanage

semanage fcontext defines the default context rules. These survive relabels.

# See the default rules for /var/www
sudo semanage fcontext -l | grep /var/www

# Add a custom rule for a new directory
sudo semanage fcontext -a -t httpd_sys_content_t "/srv/myapp(/.*)?"

# Apply the rule to existing files
sudo restorecon -Rv /srv/myapp/

SELinux Booleans

Booleans are on/off switches that modify SELinux policy without writing new rules. They handle common configuration needs.

# List all booleans
sudo getsebool -a

# List booleans related to httpd
sudo getsebool -a | grep httpd
httpd_can_network_connect --> off
httpd_can_network_connect_db --> off
httpd_can_sendmail --> off
httpd_enable_cgi --> on
httpd_enable_homedirs --> off
httpd_use_nfs --> off
# Enable Apache to make network connections (e.g., to a backend API)
sudo setsebool -P httpd_can_network_connect on
# -P makes it persistent across reboots

# Allow Apache to connect to databases
sudo setsebool -P httpd_can_network_connect_db on

# Get a description of a boolean
sudo semanage boolean -l | grep httpd_can_network

Hands-On: Troubleshoot an SELinux Denial

Scenario: You move your website files from /var/www/html to /opt/website, update the Nginx config, but the site returns 403 Forbidden.

# 1. Check if SELinux is the cause
sudo ausearch -m avc -ts recent
type=AVC msg=audit(...): avc:  denied  { read } for  pid=1234
comm="nginx" name="index.html" dev="sda1" ino=12345
scontext=system_u:system_r:httpd_t:s0
tcontext=unconfined_u:object_r:default_t:s0
tclass=file permissive=0

Reading this denial message:

  • comm="nginx" -- Nginx was denied
  • { read } -- it tried to read
  • name="index.html" -- this file
  • scontext=...httpd_t -- Nginx runs as httpd_t
  • tcontext=...default_t -- the file has type default_t
  • httpd_t is not allowed to read default_t
# 2. Check the current context
ls -Z /opt/website/index.html
unconfined_u:object_r:default_t:s0 /opt/website/index.html

The file has default_t instead of httpd_sys_content_t.

# 3. Fix it -- set the permanent rule and apply
sudo semanage fcontext -a -t httpd_sys_content_t "/opt/website(/.*)?"
sudo restorecon -Rv /opt/website/

# 4. Verify
ls -Z /opt/website/index.html
unconfined_u:object_r:httpd_sys_content_t:s0 /opt/website/index.html

The site should now work.

Using sealert for Friendly Error Messages

On RHEL/Fedora, sealert provides human-readable explanations of SELinux denials:

# Install setroubleshoot
sudo dnf install setroubleshoot-server

# Analyze the audit log
sudo sealert -a /var/log/audit/audit.log

Example output:

SELinux is preventing nginx from read access on the file index.html.

*****  Plugin restorecon (99.5 confidence) suggests  ************************

If you want to fix the label:
  /opt/website/index.html default label should be httpd_sys_content_t.
  Then you can run restorecon. The access attempt may have been stopped
  due to insufficient permissions to access a parent directory, in which
  case try to change the following command accordingly.
  Do
  # /sbin/restorecon -v /opt/website/index.html

Using audit2allow

When you need to create a custom policy module (because no existing boolean covers your use case):

# Generate a policy module from recent denials
sudo ausearch -m avc -ts recent | audit2allow -M mypolicy

# This creates:
#   mypolicy.pp  (compiled policy module)
#   mypolicy.te  (human-readable policy source)

# Review what it would allow
cat mypolicy.te

# Install the module
sudo semodule -i mypolicy.pp

WARNING: Never blindly run audit2allow and install the result. Always read the .te file first. audit2allow might generate rules that are too permissive. Sometimes the right fix is a boolean or a context change, not a new policy module.


AppArmor

AppArmor (Application Armor) is the MAC system used by Ubuntu, Debian, and SUSE. It takes a different approach from SELinux: instead of labeling everything, AppArmor uses path-based profiles that define what each program is allowed to do.

AppArmor vs SELinux: Key Differences

+-------------------+---------------------------+---------------------------+
| Feature           | SELinux                   | AppArmor                  |
+-------------------+---------------------------+---------------------------+
| Approach          | Labels on every object    | Path-based profiles       |
| Complexity        | Steeper learning curve    | Simpler to understand     |
| Granularity       | Very fine-grained         | Good, but less granular   |
| Default distro    | RHEL, Fedora, CentOS      | Ubuntu, Debian, SUSE      |
| New file handling | Inherits parent's label   | Matched by path rules     |
| Moved files       | Keep their label          | Matched by new path       |
| Profile creation  | Policy modules (complex)  | Text profiles (readable)  |
+-------------------+---------------------------+---------------------------+

AppArmor Modes

Each profile can be in one of two modes:

ModeBehavior
EnforceViolations are denied and logged
ComplainViolations are logged but allowed (like SELinux permissive)

Checking AppArmor Status

# Overall status
sudo aa-status
apparmor module is loaded.
44 profiles are loaded.
  39 profiles are in enforce mode.
    /snap/core/...
    /usr/bin/man
    /usr/sbin/mysqld
    /usr/sbin/ntpd
    ...
  5 profiles are in complain mode.
    /usr/sbin/cups-browsed
    ...
2 processes have profiles defined.
  2 are in enforce mode.
    /usr/sbin/mysqld (1234)
    /usr/sbin/ntpd (5678)
  0 are in complain mode.
  0 are unconfined but have a profile defined.

Understanding AppArmor Profiles

Profiles live in /etc/apparmor.d/ and are named after the program they confine (with slashes replaced by dots):

ls /etc/apparmor.d/
usr.sbin.mysqld
usr.sbin.ntpd
usr.bin.man
...

Let's look at a simplified profile:

sudo cat /etc/apparmor.d/usr.sbin.ntpd
# Profile for ntpd
/usr/sbin/ntpd {
  # Include common abstractions
  #include <abstractions/base>
  #include <abstractions/nameservice>

  # Capabilities the program needs
  capability net_bind_service,
  capability sys_time,

  # Files the program can read
  /etc/ntp.conf r,
  /etc/ntp/** r,
  /var/lib/ntp/** rw,
  /var/log/ntp.log w,

  # Runtime files
  /run/ntpd.pid rw,

  # Network access
  network inet dgram,
  network inet stream,
}

Profile rules use these permission flags:

FlagMeaning
rRead
wWrite
aAppend
xExecute
mMemory map as executable
kLock
lLink
ixExecute, inheriting profile
pxExecute, using target's profile
uxExecute, unconfined

Managing Profiles

# Put a profile in enforce mode
sudo aa-enforce /etc/apparmor.d/usr.sbin.mysqld

# Put a profile in complain mode (for debugging)
sudo aa-complain /etc/apparmor.d/usr.sbin.mysqld

# Disable a profile
sudo aa-disable /etc/apparmor.d/usr.sbin.mysqld

# Reload a profile after editing
sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.mysqld

# Reload all profiles
sudo systemctl reload apparmor

Hands-On: Write a Simple AppArmor Profile

Let's create a profile for a simple script that should only be allowed to read one specific file and write to a log.

# Create the script
sudo tee /usr/local/bin/myapp.sh << 'SCRIPT'
#!/bin/bash
# Read config and log activity
CONFIG=$(cat /etc/myapp/config.txt)
echo "$(date) - Config loaded: $CONFIG" >> /var/log/myapp.log
echo "App running with config: $CONFIG"
SCRIPT
sudo chmod +x /usr/local/bin/myapp.sh

# Create the config and log locations
sudo mkdir -p /etc/myapp
echo "setting=value" | sudo tee /etc/myapp/config.txt
sudo touch /var/log/myapp.log

Now create the AppArmor profile:

sudo tee /etc/apparmor.d/usr.local.bin.myapp.sh << 'PROFILE'
# AppArmor profile for /usr/local/bin/myapp.sh
/usr/local/bin/myapp.sh {
  # Include base abstractions (libc, etc.)
  #include <abstractions/base>
  #include <abstractions/bash>

  # The script itself needs to be readable
  /usr/local/bin/myapp.sh r,

  # Allow reading the config file
  /etc/myapp/config.txt r,

  # Allow writing to the log file
  /var/log/myapp.log w,

  # Allow executing common utilities
  /usr/bin/cat ix,
  /usr/bin/echo ix,
  /usr/bin/date ix,
}
PROFILE
# Load the profile in complain mode first
sudo aa-complain /etc/apparmor.d/usr.local.bin.myapp.sh

# Test the script
sudo /usr/local/bin/myapp.sh

# Check for any violations
sudo dmesg | grep ALLOWED | tail -5

# If it works, switch to enforce mode
sudo aa-enforce /etc/apparmor.d/usr.local.bin.myapp.sh

# Test again -- should still work
sudo /usr/local/bin/myapp.sh

# Now try to do something NOT in the profile
# Edit the script to also try reading /etc/shadow
sudo /usr/local/bin/myapp.sh
# The /etc/shadow read will be denied

Generating Profiles with aa-genprof

AppArmor can generate a profile by watching what a program does:

# Install the utilities
sudo apt install apparmor-utils    # Debian/Ubuntu

# Start profile generation (interactive)
sudo aa-genprof /usr/local/bin/myapp.sh

In another terminal, run the program:

sudo /usr/local/bin/myapp.sh

Back in the aa-genprof terminal, press S to scan for events, then respond to each prompt about whether to allow or deny the observed accesses. Press F to finish and save the profile.

Troubleshooting AppArmor Denials

# Check for denials in the system log
sudo dmesg | grep "apparmor" | tail -20

# Or in the system journal
sudo journalctl -k | grep "apparmor" | tail -20

# Look for DENIED entries
sudo journalctl -k | grep "DENIED"

Example denial:

audit: type=1400 audit(...): apparmor="DENIED" operation="open"
  profile="/usr/local/bin/myapp.sh" name="/etc/shadow"
  pid=1234 comm="cat" requested_mask="r" denied_mask="r" fsuid=0 ouid=0

Reading this:

  • apparmor="DENIED" -- Access was denied
  • profile="/usr/local/bin/myapp.sh" -- Which profile triggered it
  • name="/etc/shadow" -- What file was being accessed
  • requested_mask="r" -- What the program tried to do (read)

To fix: add the appropriate rule to the profile, or reconsider whether the program should actually need that access.

Using aa-logprof to Update Profiles

After running a program and encountering denials, use aa-logprof to interactively update the profile:

sudo aa-logprof

It reads the denial logs and asks whether each denied access should be allowed. This is the easiest way to refine a profile.


When to Use Which?

Use SELinux When:

  • You are running RHEL, Fedora, CentOS, Rocky, or AlmaLinux (it is already there)
  • You need fine-grained control over process interactions
  • You are in an environment with compliance requirements (PCI-DSS, HIPAA)
  • You need network-level controls (port labeling)
  • You manage servers where many services interact

Use AppArmor When:

  • You are running Ubuntu, Debian, or SUSE (it is already there)
  • You want simpler, path-based profiles
  • You need to quickly confine a specific application
  • Your team is new to MAC systems
  • You want profiles that are easy to read and audit

General Advice

Do not disable your MAC system. The single most important piece of advice in this chapter is: do not turn it off. When something breaks and you suspect SELinux or AppArmor, switch to permissive/complain mode and investigate. Fix the problem properly. Then switch back to enforcing.

  +----------------------------------------------------------+
  |  "Setenforce 0" is NOT a solution.                       |
  |  It is like removing the smoke detector because it was   |
  |  beeping. Find out WHY it is beeping.                    |
  +----------------------------------------------------------+

Think About It: You are setting up a new server. The distribution comes with SELinux in enforcing mode. A developer asks you to disable it because their application is getting permission denied errors. What is the right response?


Debug This

On a RHEL server, a developer deploys a new web application to /var/www/app and configures Nginx to serve it. The site works perfectly. Then the developer runs a backup script that uses tar to archive the site, extracts it to test on another server, and copies it back. Now the site returns 403 Forbidden. Nothing in the file permissions has changed (ls -l looks correct). SELinux is in enforcing mode.

What happened?

Answer: When tar extracted and copied the files back, the SELinux contexts were lost. The files now have default_t or unconfined_u:object_r:default_t:s0 instead of httpd_sys_content_t. Standard tar and cp do not preserve SELinux contexts by default.

Fix:

# Restore the correct contexts
sudo restorecon -Rv /var/www/app/

Prevention: Use tar --selinux or cp --preserve=context to preserve SELinux contexts during backup and copy operations:

# Tar with SELinux context preservation
tar --selinux -czf backup.tar.gz /var/www/app/

# Copy with context preservation
cp --preserve=context -r /var/www/app/ /var/www/app-backup/

What Just Happened?

+------------------------------------------------------------------+
|                    SELINUX & APPARMOR                             |
+------------------------------------------------------------------+
|                                                                  |
|  MAC vs DAC:                                                     |
|    DAC: Owner controls access (rwxr-xr-x)                       |
|    MAC: System policy controls access (regardless of owner)      |
|    Both must allow --> access granted                            |
|                                                                  |
|  SELINUX (RHEL/Fedora):                                         |
|    Labels everything: user:role:type:level                       |
|    Key commands:                                                 |
|      getenforce / setenforce     -- check/set mode               |
|      ls -Z / ps -eZ             -- view contexts                 |
|      chcon                      -- change context (temporary)    |
|      restorecon                 -- restore default context       |
|      semanage fcontext          -- set permanent rules           |
|      getsebool / setsebool      -- manage booleans               |
|      ausearch -m avc            -- find denials                  |
|      sealert                    -- friendly error messages       |
|      audit2allow                -- generate policy (use caution) |
|                                                                  |
|  APPARMOR (Ubuntu/Debian/SUSE):                                  |
|    Path-based profiles per program                               |
|    Key commands:                                                 |
|      aa-status                  -- check status                  |
|      aa-enforce / aa-complain   -- set profile mode              |
|      aa-disable                 -- disable a profile             |
|      aa-genprof                 -- generate a new profile        |
|      aa-logprof                 -- update profile from logs      |
|      apparmor_parser -r         -- reload a profile              |
|                                                                  |
|  GOLDEN RULE:                                                    |
|    Never disable. Switch to permissive/complain, diagnose,       |
|    fix properly, switch back to enforcing.                       |
|                                                                  |
+------------------------------------------------------------------+

Try This

Exercise 1: SELinux Scavenger Hunt

On a RHEL/Fedora system (or a CentOS VM):

  1. List all SELinux booleans related to samba or nfs
  2. Find the default context for files in /srv/
  3. Create a directory /srv/mysite, put an HTML file in it, configure Nginx to serve it, and fix the SELinux denial without using setenforce 0
  4. Use audit2why (from the policycoreutils-python-utils package) to analyze a denial: sudo ausearch -m avc -ts recent | audit2why

Exercise 2: AppArmor Profile from Scratch

On an Ubuntu system:

  1. Write a simple Python or bash script that reads a config file, writes a log, and optionally makes a network request
  2. Use aa-genprof to generate a profile for it
  3. Switch to enforce mode
  4. Modify the script to try accessing a file not in the profile
  5. Observe the denial in the logs
  6. Use aa-logprof to decide whether to allow or deny the new access

Exercise 3: Compare and Contrast

If you have access to both a RHEL-family and Ubuntu system:

  1. Deploy the same simple web application on both
  2. Move the web files to a non-standard directory on both
  3. Fix the MAC denial on both systems
  4. Compare the experience: which was easier? Which gave better error messages? Which felt more secure?

Bonus Challenge

Create an AppArmor profile for curl that only allows it to connect to a specific list of domains. Test it by trying to curl an allowed domain (should work) and a disallowed domain (should be denied). This demonstrates how MAC can enforce network policies per-application.