Skip to main content
RunBook Academy

← All labs in Linux

Lab · intermediate · ~45 min

Lab: Build an AppArmor profile for a service

B · Nested virtualisationC · Simulation

Objectives

  • Build an AppArmor profile for a service
  • Switch between complain and enforce modes
  • Tune the profile for legitimate access
  • Verify the profile blocks malicious access

Prerequisites

This lab builds an AppArmor profile for nginx from scratch, testing both legitimate access (must work) and malicious access (must be blocked). By the end you will have a production-style profile.

Objective

By the end of this lab, you can:

  • Use aa-genprof to build a profile.
  • Switch profiles between complain and enforce.
  • Test that legitimate access works.
  • Test that malicious access is blocked.

Architecture

You need a Debian-family host (Debian, Ubuntu) with:

  • AppArmor installed and running.
  • nginx installed.
  • A test page in /var/www/html/.

Tasks

Task 1: Verify AppArmor is active

sudo systemctl status apparmor
aa-status

Expected: AppArmor is loaded with several default profiles.

Task 2: Put nginx in complain mode

sudo aa-complain /usr/sbin/nginx
aa-status | grep nginx

Should show nginx (complain).

Task 3: Back up the shipped profile, then generate yours

Debian and Ubuntu ship an nginx profile. You are about to overwrite it, so take a copy first. Without this you cannot put the host back the way you found it.

sudo cp -a /etc/apparmor.d/usr.sbin.nginx \
           /etc/apparmor.d/usr.sbin.nginx.orig
ls -l /etc/apparmor.d/usr.sbin.nginx*

If the file does not exist on your host, record that fact — cleanup will then mean deleting your profile rather than restoring one.

Start nginx:

sudo systemctl start nginx

Run the application and observe:

curl http://localhost/

Wait 30 seconds for aa-genprof to capture accesses (if running interactively). Or check the audit log:

sudo aa-logprof

Interactively review the captured accesses. Accept the defaults to build a minimal profile:

# /etc/apparmor.d/usr.sbin.nginx

#include <tunables/global>

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

  capability dac_override,
  capability net_bind_service,

  /etc/nginx/** r,
  /var/log/nginx/** w,
  /var/www/** r,
  /run/nginx.pid w,

  network inet stream,
  network inet6 stream,

  audit deny /etc/shadow* rw,
}

Save to /etc/apparmor.d/usr.sbin.nginx, then parse-check it before loading. An #include of an abstraction that does not exist on this system is a hard error, not a warning, and the profile simply will not load:

# Which abstractions does this host actually ship?
ls /etc/apparmor.d/abstractions/

# Parse only - loads nothing, changes nothing
sudo apparmor_parser -Q /etc/apparmor.d/usr.sbin.nginx && echo "profile parses"

Guessing an abstraction name by the daemon’s name does not work: there is no abstractions/nginx. apparmor_parser reports Could not open 'abstractions/nginx' and refuses the whole file.

Task 4: Enforce the profile

sudo aa-enforce /usr/sbin/nginx
aa-status | grep nginx

Should show nginx (enforce).

Task 5: Test legitimate access

curl http://localhost/

Should succeed. nginx can read its config, logs, and web content per the profile.

Task 6: Test malicious access

Try to get nginx to read a file it should not:

echo "test" | sudo tee /etc/nginx/test.conf

# Restart nginx - the profile should deny reading this file
sudo systemctl restart nginx

Check the audit log:

sudo journalctl -k -n 20 | grep nginx
sudo dmesg | grep nginx

The denial should be logged but nginx should still serve content (because the file is not in the served path).

Task 7: Prove the profile blocks a write outside its allow-list

You will point nginx’s access log at a path under /etc/ that the profile denies, and watch AppArmor stop it.

Create the drop-in in the http context:

sudo tee /etc/nginx/conf.d/zz-lab-denial.conf >/dev/null <<'EOF'
# Valid http-context directive aimed at a denied path.
access_log /etc/shadow.lab-test;
EOF

Test the configuration without restarting anything:

sudo nginx -t

Expect a failure like:

nginx: [emerg] open() "/etc/shadow.lab-test" failed (13: Permission denied)
nginx: configuration file /etc/nginx/nginx.conf test failed

That is the correct, honest result. nginx does not carry on serving with a log target it cannot open — it aborts. An access denial on a log file is a startup failure, not a silent no-op. This is the whole reason nginx -t exists: you find out before you restart, while the running worker processes are still up.

Confirm AppArmor is the reason, not file permissions:

sudo journalctl -k -n 50 | grep -i 'apparmor.*nginx'
sudo dmesg | grep -i 'apparmor.*shadow'

You should see apparmor="DENIED" operation="open" with name="/etc/shadow.lab-test" and profile="/usr/sbin/nginx". If you see nothing, check that you used audit deny and not plain deny.

Now remove the drop-in and confirm the service is healthy again before moving on:

sudo rm -f /etc/nginx/conf.d/zz-lab-denial.conf
sudo nginx -t && sudo systemctl restart nginx
curl -sf http://localhost/ >/dev/null && echo 'nginx OK'

Never leave a failing config in place between tasks. The next systemctl restart nginx from anyone else — a package upgrade, a config-management run — would fail for reasons that have nothing to do with them.

Task 8: Add deny rules for other sensitive paths

Edit /etc/apparmor.d/usr.sbin.nginx and extend the deny block:

audit deny /etc/shadow* rw,
audit deny /etc/ssh/** rw,
audit deny /root/** rwx,

Reload the profile into the kernel and confirm it took:

sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.nginx
aa-status | grep nginx

apparmor_parser -r replaces the profile for processes started from now on and for already-running confined processes. If the parser reports a syntax error the old profile stays loaded — read the error, do not assume the reload worked.

Task 9: Test that the profile blocks the SSH config

Same shape as Task 7, different target:

sudo tee /etc/nginx/conf.d/zz-lab-denial.conf >/dev/null <<'EOF'
access_log /etc/ssh/sshd_config.lab-test;
EOF

sudo nginx -t
sudo journalctl -k -n 50 | grep -i 'apparmor.*sshd_config'

Expect nginx -t to fail with permission denied and an apparmor="DENIED" record naming the path.

Clean up immediately, as before:

sudo rm -f /etc/nginx/conf.d/zz-lab-denial.conf
sudo nginx -t && sudo systemctl restart nginx
curl -sf http://localhost/ >/dev/null && echo 'nginx OK'

Task 10: Document the profile

APPARMOR PROFILE
=================
Service: nginx
Profile: /etc/apparmor.d/usr.sbin.nginx
Mode: enforce
Generated: 2026-08-09

Capabilities:
- dac_override: needed to read /etc/nginx
- net_bind_service: bind to port 80

File access:
- /etc/nginx/** r: read configuration
- /var/log/nginx/** w: write logs
- /var/www/** r: read web content
- /run/nginx.pid w: write PID file

Network:
- TCP IPv4 and IPv6

Explicit denies (audit deny, so they are logged):
- /etc/shadow*
- /etc/ssh/**
- /root/**

Verification:
- curl http://localhost/ succeeds
- access_log /etc/shadow.lab-test: nginx -t fails,
  apparmor="DENIED" logged
- access_log /etc/ssh/sshd_config.lab-test: nginx -t fails,
  apparmor="DENIED" logged

Validation

  • The profile is in /etc/apparmor.d/usr.sbin.nginx and aa-status shows nginx in enforce mode.
  • The shipped profile is backed up at /etc/apparmor.d/usr.sbin.nginx.orig.
  • sudo nginx -t passes and curl -sf http://localhost/ returns the test page.
  • Pointing a log at a denied path makes nginx -t fail with (13: Permission denied) — not a silent success.
  • Each denial produced an apparmor="DENIED" record in the kernel log.
  • No zz-lab-denial.conf remains in /etc/nginx/conf.d/.

Cleanup

Undo everything, in this order. The last two commands are the proof, not the hope.

# 1. Remove any lab drop-in and the Task 6 test file
sudo rm -f /etc/nginx/conf.d/zz-lab-denial.conf
sudo rm -f /etc/nginx/test.conf

# 2. Restore the distribution profile you backed up in Task 3
sudo cp -a /etc/apparmor.d/usr.sbin.nginx.orig \
           /etc/apparmor.d/usr.sbin.nginx
sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.nginx
sudo aa-enforce /usr/sbin/nginx

# 3. Bring nginx back and verify
sudo nginx -t
sudo systemctl restart nginx
curl -sf http://localhost/ >/dev/null && echo 'nginx OK'
aa-status | grep nginx

The final aa-status must show nginx under enforce.

What you learned

  • AppArmor profiles are built by observing accesses.
  • complain mode captures; enforce mode blocks.
  • Explicit deny rules harden the profile, but they are audit-quiet unless written as audit deny.
  • A denied file access is usually a startup failure, not a silent no-op. Confined services abort rather than continue half-configured.
  • Test denials through a disposable drop-in, never by appending to a live site file — and never aim a write at a real credential file.
  • Verification requires testing both legitimate and malicious access, and cleanup must restore the host to enforce mode with the service running.

Deliverables

  • · A working AppArmor profile for nginx
  • · Test that legitimate access works
  • · Test that malicious access is blocked

Verification status

Last reviewed
2026-08-09
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.