EthicsComputerCourses
Join training +91 90532 10052

SELinux & AppArmor

Linux · Lesson 42 of 45 Project 8 min read Updated

Normal Linux permissions, owner, group and others, are called discretionary access control (DAC): the owner of a file decides who may use it, and root may do anything. That leaves a gap. If an attacker breaks into nginx through a bug, the attacker gets everything the nginx user can touch, and if a service runs as root, everything at all.

Mandatory access control (MAC) closes that gap. A central policy says what each program may do, regardless of file permissions and even for root: the web server may read web content and connect to its backend, and nothing else. Linux has two widely used MAC systems: SELinux, enabled by default on RHEL, Rocky, AlmaLinux and Fedora, and AppArmor, enabled by default on Ubuntu and Debian. This lesson explains both and shows the most common real problem: a denial that breaks an application, and how to fix it correctly instead of switching protection off.

SELinux: modes and labels

SELinux runs in one of three modes: enforcing (denials are blocked and logged), permissive (nothing is blocked, but would-be denials are logged) and disabled.

Terminal
getenforce
sestatus | head -n 5
Output
Enforcing
SELinux status:                 enabled
SELinuxfs mount:                /sys/fs/selinux
SELinux root directory:         /etc/selinux
Loaded policy name:             targeted
Current mode:                   enforcing

The targeted policy confines network-facing services such as web servers, databases and SSH, while ordinary user programs run largely unconfined. The mode set at boot lives in /etc/selinux/config; sudo setenforce 0 switches to permissive until the next reboot, which is useful for one quick test but nothing more.

SELinux decides using labels (also called contexts) on every process and file, shown with -Z:

Terminal
ps -eZ | grep nginx | head -n 1
ls -Zd /usr/share/nginx/html /srv/shop
Output
system_u:system_r:httpd_t:s0        1520 ?        00:00:00 nginx
system_u:object_r:httpd_sys_content_t:s0 /usr/share/nginx/html
unconfined_u:object_r:var_t:s0 /srv/shop

A label has four parts, user:role:type:level, but you mostly care about the type, the third part. The policy is a long list of rules such as "processes of type httpd_t may read files of type httpd_sys_content_t". nginx runs as httpd_t (all web servers share this type). The default web folder has the right type; Priya's new folder /srv/shop has the generic var_t, so nginx will get 403 Forbidden there even though chmod permissions are perfect.

Fixing SELinux denials properly

First, confirm that SELinux really is the cause. Denials are recorded by the audit system:

Terminal
sudo ausearch -m AVC -ts recent
Output
----
time->Mon Nov  2 10:14:07 2026
type=AVC msg=audit(1793594647.212:412): avc:  denied  { read } for  pid=1521 comm="nginx" name="index.html" dev="dm-0" ino=1310722 scontext=system_u:system_r:httpd_t:s0 tcontext=unconfined_u:object_r:var_t:s0 tclass=file permissive=0

Read it as a sentence: process nginx with type httpd_t (scontext) was denied read on a file (tclass) named index.html with type var_t (tcontext). The fix is to give the folder the right type, permanently. semanage fcontext adds a rule to the policy's list of default labels, and restorecon applies the list to the files:

Terminal
sudo semanage fcontext -a -t httpd_sys_content_t "/srv/shop(/.*)?"
sudo restorecon -Rv /srv/shop
Output
Relabeled /srv/shop from unconfined_u:object_r:var_t:s0 to unconfined_u:object_r:httpd_sys_content_t:s0
Relabeled /srv/shop/index.html from unconfined_u:object_r:var_t:s0 to unconfined_u:object_r:httpd_sys_content_t:s0

The pattern "/srv/shop(/.*)?" covers the folder and everything inside. Because the rule is now in the policy, new files and future relabels keep the right type. (semanage comes from the policycoreutils-python-utils package.) Never use chcon for permanent fixes: it changes the label directly, and the next full relabel undoes it.

The second common case is a behaviour that the policy can switch on with a boolean, an on/off setting. For example, nginx acting as a reverse proxy must connect to an application on another port, which the policy blocks by default:

Terminal
getsebool httpd_can_network_connect
sudo setsebool -P httpd_can_network_connect on
getsebool httpd_can_network_connect
Output
httpd_can_network_connect --> off
httpd_can_network_connect --> on

-P makes the change permanent. There are dozens of booleans for web servers alone, covering things like sending mail or reading users' home folders; turn on only the one you need.

AppArmor on Ubuntu

AppArmor confines programs with profiles stored in /etc/apparmor.d/. Instead of labels, a profile lists file paths a program may use, with permissions such as r (read), w (write) and k (lock). Profiles are either in enforce mode (blocked and logged) or complain mode (only logged).

Terminal
sudo aa-status | head -n 4
sudo aa-status | grep -A1 'processes are in enforce'
Output
apparmor module is loaded.
121 profiles are loaded.
68 profiles are in enforce mode.
   /usr/bin/man
2 processes are in enforce mode.
   /usr/sbin/mysqld (1120)

The counts vary from system to system. Not every service has a profile. On Ubuntu, packages such as MySQL, chrony and tcpdump ship one, and Ubuntu 25.04 added many new application profiles, but many other services, including nginx by default, run unconfined.

The classic AppArmor problem is moving data to a new disk. Rohan moves the MySQL data folder to /srv/mysql, updates datadir in the MySQL config, and the database refuses to start. The kernel log shows why:

Terminal
journalctl -k -g 'apparmor="DENIED"' -n 1
Output
Nov 02 10:20:11 shop-server kernel: audit: type=1400 audit(1793595011.004:88): apparmor="DENIED" operation="open" class="file" profile="/usr/sbin/mysqld" name="/srv/mysql/ibdata1" pid=1120 comm="mysqld" requested_mask="wr" denied_mask="wr" fsuid=113 ouid=113

The profile /usr/sbin/mysqld allows the standard /var/lib/mysql/ path but not /srv/mysql/. Do not edit the packaged profile: add local rules to its local include file, which package updates leave alone, then reload the profile:

Terminal
printf '/srv/mysql/ r,\n/srv/mysql/** rwk,\n' | sudo tee -a /etc/apparmor.d/local/usr.sbin.mysqld
sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.mysqld
sudo systemctl start mysql

The first rule allows reading the folder itself, the second read, write and lock on everything below it. With the apparmor-utils package, sudo aa-complain /usr/sbin/mysqld puts one profile in complain mode while you investigate, sudo aa-enforce turns enforcement back on, and sudo aa-logprof reads the logged denials and offers to add matching rules.

Common mistakes

  • Disabling SELinux or AppArmor to "fix" an error. You remove a whole layer of protection; fix the label, boolean or profile instead.
  • Using chcon or mv and expecting labels to stay. mv keeps the old label from the source; restorecon with an fcontext rule is the lasting fix.
  • Assuming permissions are the problem. If ls -l looks right but access fails, check ausearch -m AVC or the AppArmor kernel log.
  • Editing packaged AppArmor profiles. Use /etc/apparmor.d/local/ so updates keep your rules.
  • Leaving a profile in complain mode after testing. It protects nothing.

Key points

  • MAC limits what each program may do, even as root; DAC is ordinary owner/group permissions.
  • SELinux (RHEL family) uses type labels; check with getenforce, ls -Z and ps -eZ.
  • Fix SELinux labels with semanage fcontext plus restorecon, and behaviours with setsebool -P.
  • Find SELinux denials with ausearch -m AVC and explain them with audit2why.
  • AppArmor (Ubuntu) uses path-based profiles; check with aa-status, add rules in /etc/apparmor.d/local/ and reload with apparmor_parser -r.

Exercise

On a Rocky Linux server, Kabir deploys a site in /data/www and configures nginx to proxy /api/ to an app on 127.0.0.1:8000. The site returns 403 and the API returns 502, although file permissions are correct. Write the commands to confirm SELinux is responsible and to fix both problems permanently.

Show solution
Terminal
sudo ausearch -m AVC -ts recent | audit2why
sudo semanage fcontext -a -t httpd_sys_content_t "/data/www(/.*)?"
sudo restorecon -Rv /data/www
sudo setsebool -P httpd_can_network_connect on
curl -sI http://localhost/ | head -n 1
curl -sI http://localhost/api/health | head -n 1
Output
HTTP/1.1 200 OK
HTTP/1.1 200 OK

audit2why confirms two denials: reading files labelled default_t and a network connection from httpd_t. The fcontext rule plus restorecon give the content the web type, and the boolean allows nginx to connect to the backend. SELinux stays in enforcing mode the whole time.

Quick Quiz

1. Which part of an SELinux label matters most in the targeted policy?

2. Which pair of commands gives /srv/shop the web content label permanently?

3. Where do you add your own rules for a packaged AppArmor profile on Ubuntu?

4. What does SELinux permissive mode do?

Learn Linux with a mentor

Free lessons get you started. Our Ethical Hacking & Cyber Security training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call