The Reality of Linux Security

Traditional Unix permissions are elegant, simple, and fundamentally broken for modern threat models. The problem isn't that rwx permissions fail—they work exactly as intended when Darius Ritchie was hacking PDP-11s in 1971. The problem is that once any user (including root-compromised processes) has access to a file, they have complete discretion over it. A compromised web server running as www-data can read your SSH keys, exfiltrate databases, and drop miners in /tmp because traditional Discretionary Access Control (DAC) assumes users voluntarily restrict themselves.

Nobody voluntarily restricts themselves—not attackers, and frankly, most applications either. This is where Mandatory Access Control changes the game entirely.

MAC vs DAC: Understanding the Shift

Discretionary Access Control is the traditional Unix model: resource owners decide who gets access. If I own a file, I set the permissions. If Apache owns its processes, Apache decides what those processes can touch. The discretion belongs to the subject.

Mandatory Access Control inverts this relationship. A system-wide security policy defines what each process must be allowed to do, regardless of what the process itself wants. Even if Apache is compromised and running as root, MAC restrictions still apply. The policy is mandatory—it cannot be overridden by the subject.

Think of it this way: DAC is like a building where employees have keys to offices they own. MAC is a security guard at every door checking a master list of who's allowed where, regardless of whose keycard they're waving around.

Linux implements MAC through the Linux Security Modules (LSM) framework. The two heavyweights are SELinux (NSA's contribution) and AppArmor (from SUSE/Canonical). Both enforce MAC policies, but they approach the problem differently—and that difference matters in production environments.

AppArmor vs SELinux: Path-based vs Label-based Security

SELinux is powerful, flexible, and notoriously complex. It uses a label-based security model where every object (files, processes, ports) carries security context labels. Policies define relationships between label types: httpd_t processes can read httpd_sys_content_t files but not shadow_t files. Move a file, and you must update its label or the policy breaks. Labels are orthogonal to the filesystem—they're stored in extended attributes.

AppArmor takes a pragmatically different approach: path-based security. Instead of labeling objects, AppArmor policies specify which paths a program can access. The profile for /usr/sbin/nginx explicitly lists /var/www/html/** as accessible. Move nginx to /opt/nginx/bin/nginx and you need a new profile—but you never need to touch extended attributes or relabel your entire filesystem.

Aspect AppArmor SELinux
Policy Model Path-based (file paths define boundaries) Label-based (security contexts define boundaries)
Default Stance Default-deny with explicit allow rules Default-deny with explicit allow rules
File Movement Policy follows path automatically Requires relabeling after moves
Policy Language Domain-specific, path-oriented syntax TE rules, role-based, multi-level security
Learning Curve Hours to productive profiles Days to weeks for proficiency
Default on Ubuntu, Debian, SUSE RHEL, CentOS, Fedora

My take? If you're managing RHEL fleets or need MLS/LSPP compliance, learn SELinux. But for most production web servers, containers, and standard Linux deployments, AppArmor delivers 80% of the security benefit with 20% of the cognitive overhead. The path-based model maps directly onto how sysadmins already think about filesystem layouts.

Practical Profile Anatomy

Let's dissect a real profile. Here's what AppArmor generates for a typical service:

bash
# /etc/apparmor.d/usr.sbin.nginx
#include <tunables/global>

profile nginx /usr/sbin/nginx {
  #include <abstractions/base>
  #include <abstractions/nameservice>

  capability net_bind_service,
  capability setuid,
  capability setgid,

  /usr/sbin/nginx mr,
  /etc/nginx/** r,
  /etc/nginx/sites-enabled/ r,
  /var/www/html/** r,
  /var/log/nginx/** rw,
  /run/nginx.pid rw,
  /var/lib/nginx/** rw,

  /etc/ssl/certs/** r,
  /etc/ssl/private/** r,

  deny /etc/shadow r,
  deny /root/** rwx,
  deny /home/** rwx,
}

Key elements to understand:

  • Path mapping: The profile name maps to a binary path, but must be explicitly defined: profile nginx /usr/sbin/nginx
  • Abstractions: #include <abstractions/base> pulls in common patterns (TTY access, basic library loading)
  • Capability declarations: Explicit Linux capabilities required (binding low ports, dropping privileges)
  • Permission modes: r (read), w (write), rw (both), m (memory map), x (execute), ix (inherit), ux (unconfined), Px (transition to profile)
  • Deny rules: Explicit blocks override any allows—deny /etc/shadow r ensures this file stays unreadable even if other rules might allow it

Real-World Scenario: Locking Down Nginx

Here's a production-tightened profile for nginx that restricts it to strictly necessary operations:

bash
# /etc/apparmor.d/usr.sbin.nginx-hardened
#include <tunables/global>

profile nginx-hardened /usr/sbin/nginx {
  #include <abstractions/base>
  #include <abstractions/nameservice>
  #include <abstractions/ssl_certs>

  # Required capabilities only
  capability net_bind_service,
  capability setuid,
  capability setgid,
  capability sys_resource,
  deny capability dac_override,
  deny capability dac_read_search,

  # Binary and libraries
  /usr/sbin/nginx mr,
  /usr/lib/nginx/modules/*.so mr,

  # Configuration - read-only
  /etc/nginx/nginx.conf r,
  /etc/nginx/mime.types r,
  /etc/nginx/conf.d/*.conf r,
  /etc/nginx/sites-enabled/* r,
  /etc/nginx/snippets/*.conf r,

  # Deny access to parent config dirs with secrets
  deny /etc/nginx/ssl/** rw,
  deny /etc/nginx/htpasswd* rw,
  deny /etc/nginx/secrets/** rw,

  # Web content - strict path only
  /var/www/{html,staging,assets}/** r,
  deny /var/www/** w,
  deny /var/www/**/*.php rwx,
  deny /var/www/**/*.py rwx,
  deny /var/www/**/*.sh rwx,

  # Logs with rotation handling
  /var/log/nginx/*.log rw,
  /var/log/nginx/*.log.* r,
  /var/log/nginx/ w,

  # Runtime
  /run/nginx.pid rwk,
  /run/nginx.pid.* rwk,
  /tmp/nginx_* rw,
  /var/lib/nginx/** rw,

  # System interfaces
  /proc/[^0-9]*/** r,  # Deny access to other process info
  /proc/sys/net/core/somaxconn r,
  /sys/devices/system/cpu/online r,

  # Networking
  network inet stream,
  network inet6 stream,
  deny network raw,
  deny network packet,

  # Explicit denies for defense-in-depth
  deny /etc/passwd* rw,
  deny /etc/group* rw,
  deny /etc/shadow* rw,
  deny /etc/hosts* rw,
  deny /root/** rwx,
  deny /home/** rwx,
  deny /proc/*/fd/** rwx,
  deny /tmp/ rwx,
  deny /var/tmp/** rwx,
  deny /dev/tty* rw,
  deny /dev/pts/* rw,
  deny /bin/** rix,
  deny /usr/bin/** rix,
  deny /sbin/** rix,
}

This profile implements several hardening principles:

  • Capability minimization: Only the specific capabilities nginx needs are granted
  • Path whitelisting: Explicit /var/www/{html,staging,assets}/** rather than loose /var/www/**
  • Executable blocking: Denying PHP, Python, and shell scripts prevents malicious file execution even if uploaded
  • Lateral movement prevention: Home directories, /tmp execution, and shell access are blocked
  • Proc restriction: Process information from other PIDs is denied

Real-World Scenario: Isolating Python Applications

Python web apps—Flask, Django, FastAPI—are attack magnets when they run as full users. Here's a profile for a typical Gunicorn-based deployment:

bash
# /etc/apparmor.d/usr.local.bin.gunicorn-app
#include <tunables/global>

profile gunicorn-app /usr/local/bin/gunicorn-app {
  #include <abstractions/base>
  #include <abstractions/nameservice>
  #include <abstractions/python>

  # Process capabilities
  capability net_bind_service,
  capability setuid,
  capability setgid,
  capability kill,

  # Application binary and venv
  /usr/local/bin/gunicorn-app r,
  /opt/app/venv/bin/python3 mr,
  /opt/app/venv/** mr,
  /opt/app/venv/lib/python3.*/** mr,

  # Application code - read only
  /opt/app/src/** r,
  /opt/app/config/*.py r,
  /opt/app/config/*.json r,
  /opt/app/config/*.yaml r,
  /opt/app/templates/** r,
  /opt/app/static/** r,

  # Explicit code block
  deny /opt/app/**/*.pyc w,
  deny /opt/app/__pycache__/** w,
  deny /opt/app/venv/**/*.pyc w,

  # Uploads - write only to specific directory
  /opt/app/uploads/** rw,
  /opt/app/media/** rw,

  # Deny execution from uploads
  deny /opt/app/uploads/**/*.py rwx,
  deny /opt/app/uploads/**/*.sh rwx,
  deny /opt/app/uploads/**/bin/* rwx,
  deny /opt/app/media/**/*.py rwx,

  # Databases
  /opt/app/data/*.db rwk,
  /opt/app/data/**/*.sqlite rwk,
  /var/lib/postgresql/ r,  # Only if using local socket
  /run/postgresql/.s.PGSQL.** rw,

  # Caches and temp
  /opt/app/.cache/** rw,
  /tmp/app_* rw,
  /var/tmp/app_* rw,
  deny /tmp/** rwx,
  deny /var/tmp/** rwx,

  # Logs
  /var/log/app/*.log rw,
  /var/log/app/ w,

  # Runtime
  /run/app/*.pid rwk,
  /run/app/*.sock rw,

  # Secrets - explicit deny
  deny /opt/app/.env* rk,
  deny /opt/app/**/.env* rk,
  deny /opt/app/secrets/** rk,
  deny /opt/app/**/*.key rk,
  deny /opt/app/**/private* rk,

  # System lockdown
  deny /etc/passwd rw,
  deny /etc/shadow rw,
  deny /etc/hosts rw,
  deny /root/** rwx,
  deny /home/** rwx,
  deny /var/spool/** rwx,
  deny /proc/*/mem r,
  deny /proc/*/maps r,
  deny /proc/*/environ r,
  deny /proc/sys/kernel/** rw,

  # Network controls
  network inet stream,
  network inet6 stream,
  deny network inet dgram,
  deny network inet6 dgram,
  deny network raw,
}

Key isolation principles for Python apps:

  • Virtual environment containment: Code runs from /opt/app/venv only
  • Upload quarantine: Uploaded files go to uploads/ with execution explicitly denied
  • Secret isolation: .env files at any depth are unreadable by the app process
  • Process info blocking: /proc/[pid]/environ blocked to prevent credential leakage
  • Bytecode control: .pyc writes denied to prevent cache poisoning

Development to Production Workflow

Here's my battle-tested workflow for deploying AppArmor profiles:

Step 1: Generate Initial Profile

bash
# Install utilities first
sudo apt install apparmor-utils apparmor-profiles-extra

# Generate profile in complain mode
sudo aa-genprof /usr/sbin/myapp

# Run your app through normal operations
# Exercise all features: uploads, database, config reloads

# Return to aa-genprof terminal, press S to scan
# Review each rule, use (A)llow or (D)eny
# Save and finish

Step 2: Harden the Generated Profile

The generated profile is intentionally permissive. Review and tighten:

bash
# Open your profile
sudo nano /etc/apparmor.d/usr.sbin.myapp

# Tighten glob patterns:
#   BEFORE: /var/www/** rw
#   AFTER:  /var/www/html/** r, /var/www/uploads/** rw

# Remove unnecessary capabilities
#   BEFORE: capability dac_override,
#   AFTER:  # capability dac_override,  (commented or removed)

# Add explicit denies for sensitive paths
deny /etc/shadow r,
deny /root/** rwx,
deny /home/** rwx,

Step 3: Testing in Staging

bash
# First: complain mode for testing
sudo aa-complain /etc/apparmor.d/usr.sbin.myapp

# Restart your service
sudo systemctl restart myapp

# Load test heavily, check logs for violations
sudo aa-logprof  # Interactive log processor
# Or tail syslog:
sudo tail -f /var/log/syslog | grep apparmor

# Watch for DENIED entries - these break in enforce mode

Step 4: Enforce Mode

bash
# Switch to enforce
sudo aa-enforce /etc/apparmor.d/usr.sbin.myapp

# Reload AppArmor (not full restart - keeps existing profiles)
sudo systemctl reload apparmor

# Monitor for regressions
cat /var/log/audit/audit.log | grep DENIED  # On systems with auditd
journalctl -xe | grep -i apparmor

Profile Management at Scale

When managing multiple systems, consistency matters. Here's how I organize:

bash
# Directory structure strategy
/etc/apparmor.d/
├── local/              # Site-specific customizations
│   ├── usr.sbin.nginx  # Overrides for distro profile
│   └── usr.bin.python3.11
├── local-bin/          # Custom application profiles
│   ├── gunicorn-app
│   └── celery-worker
├── abstractions/
│   └── custom/           # Your own abstractions
│       └── python-app    # Common Python patterns
└── tunables/
    └── site/             # Site-specific variables

Create reusable abstractions for common patterns:

bash
# /etc/apparmor.d/abstractions/custom/webapp-base
#include <abstractions/base>
#include <abstractions/nameservice>
#include <abstractions/ssl_certs>

  capability net_bind_service,
  capability setuid,
  capability setgid,

  deny /etc/passwd rw,
  deny /etc/shadow rw,
  deny /root/** rwx,
  deny /home/** rwx,

Troubleshooting Common Issues

Profile Loading Failures

bash
# Check profile syntax before applying
cat /etc/apparmor.d/usr.sbin.myapp | apparmor_parser -n  # Syntax check

# Common errors:
# - Missing commas at end of lines
# - Double slashes in paths (use /** for dirs)
# - Invalid capability names

Unexpected Denials

bash
# Real-time denial monitoring with context
sudo tail -f /var/log/syslog | grep -E "(apparmor|DENIED|audit)"

# Detailed analysis of specific process
sudo aa-exec -p usr.sbin.myapp -- /bin/bash  # Test shell in profile
ls /etc/shadow  # Should fail if profile is working

Updating Active Profiles

bash
# Edit the profile
sudo nano /etc/apparmor.d/usr.sbin.myapp

# Reload only this profile (not all of AppArmor)
sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.myapp

# Full service restart if needed
sudo systemctl reload myapp

Quick Reference: Essential Commands

Command Purpose
sudo aa-status View loaded profiles and enforcement status
sudo aa-enforce /path/to/profile Enable enforcement mode on profile
sudo aa-complain /path/to/profile Switch profile to complain (logging only) mode
sudo aa-disable /path/to/profile Completely disable a profile
sudo aa-genprof /path/to/binary Interactive profile generator
sudo aa-logprof Update profile from audit logs
sudo aa-notify -s 1 -v Desktop notification for denials
sudo apparmor_parser -r /path/to/profile Reload specific profile without restart
sudo apparmor_parser -n < profile Syntax check profile
sudo aa-exec -p profile -- command Run command under specific profile

Final Thoughts

AppArmor won't make you bulletproof—no security control will. But when a compromised web app tries to cat /etc/shadow or download a miner to /tmp, that DENIED log entry in your syslog is the moment you know your defense-in-depth strategy is working.

The path-based model works well because it mirrors how we already organize systems. You don't need to relabel your filesystem or understand type enforcement hierarchies. You write rules about what paths processes can touch, which is what you were already thinking about when you set up chroot jails and restricted service users.

Start with complain mode, profile your applications under real workloads, tighten iteratively, and enforce when you're confident. The 30 minutes spent crafting a solid AppArmor profile will save you days of incident response when—not if—something gets compromised.

Remember: AppArmor is a safety net, not a replacement for patching, input validation, or secure coding. Layer your defenses. The most secure system has both AppArmor blocking exploits and developers writing code that never triggers those blocks.