Confining a Service With AppArmor: A Profile That Contains the Blast Radius

When a service is exploited, the interesting question is not whether the attacker got in. It is how much of the filesystem they can read once they are running as that service. AppArmor is where you answer that question in advance.

A web-facing daemon runs as its own user, which feels like containment until you remember that its user can usually read most of the filesystem: config files two directories over, the odd private key someone left with loose permissions, the shape of the whole estate. A remote-code-execution bug in that daemon inherits all of it. A mandatory access control profile changes the arithmetic. It says, in the kernel, that this binary may read exactly these paths, bind exactly these ports, and hold exactly these capabilities, and nothing beyond that list happens regardless of what the process asks for. This is an afternoon of work on Ubuntu 24.04 that pays off precisely once, during an incident, which is the only time it matters.

Learn the real access pattern in complain mode

You do not write an AppArmor profile from first principles. You observe what the service actually does and encode that, because the gap between what an application is documented to touch and what it touches in practice is where enforced profiles break in production. The tool for this is aa-genprof, from the apparmor-utils package (version 4.0.1 on 24.04). It creates a skeleton profile, puts it in complain mode, and watches.

Free · 4 minutes

Would you survive contact with a determined attacker — or an auditor?

Fourteen questions on access, patching, detection, and recovery — the basics that prevent most real incidents, and the ones most often assumed rather than verified. Banded finding on screen, full sheet by email.

Complain mode is the whole trick. The profile is loaded, every access that would violate it is logged, but nothing is blocked. You run the service through its real workload, come back, and let the tool turn the logged accesses into rules.

sudo apt install apparmor-utils

# Start learning. This creates /etc/apparmor.d/usr.local.bin.reportd
# in complain mode and waits.
sudo aa-genprof /usr/local/bin/reportd

# In another shell: exercise the service properly.
# Hit every endpoint, run the scheduled job, trigger the error path,
# rotate the logs. Coverage here is what makes enforce mode survivable.

# Back in aa-genprof: press S to scan the logs, then A to allow each
# access it found (or D to deny), and F to finish.

Be deliberate about coverage. If a code path never runs during the learning window, the access it needs never gets logged, and it becomes a denial the first time it fires under enforcement. Exercise the cron job, the health check, the log rotation and the shutdown handler, not just the happy-path HTTP route.

Tighten the profile by hand

The generated profile is a starting point, not the finished article. It tends to be too generous with wildcards and too literal about the paths it happened to see. Open it and make it say what you mean. A hand-tightened profile for a Python service looks roughly like this.

abi <abi/4.0>,

include <tunables/global>

profile reportd /usr/local/bin/reportd flags=(complain) {
  include <abstractions/base>
  include <abstractions/nameservice>
  include <abstractions/openssl>

  # Capabilities: bind a privileged port, nothing else.
  capability net_bind_service,

  # Network: TCP over IPv4 and IPv6 only. No raw sockets, no packet capture.
  network inet stream,
  network inet6 stream,

  # The binary and its interpreter.
  /usr/local/bin/reportd r,
  /usr/bin/python3.12 ix,
  /usr/lib/python3.12/** r,

  # Configuration is read-only to the service.
  /etc/reportd/ r,
  /etc/reportd/** r,

  # State and logs are the only writable trees, and nothing above them.
  /var/lib/reportd/ rw,
  /var/lib/reportd/** rwk,
  /var/log/reportd/ rw,
  /var/log/reportd/*.log rw,

  # Runtime socket and pid file.
  /run/reportd/ rw,
  /run/reportd/** rw,
}

Read that as a deny-by-default statement. The service can read its own configuration but not write it, so a compromise cannot rewrite its config to disable a control. It can write only under /var/lib/reportd and /var/log/reportd, so it cannot drop a file into a web root or a home directory. It holds one capability, net_bind_service, so even running effectively as root it cannot load a kernel module or read arbitrary files by way of CAP_DAC_OVERRIDE. Everything not listed is denied. That is the blast radius, written down and enforced by the kernel rather than hoped for.

Keep the profile in version control next to the service it protects, and treat a change to it like any other change to a security control. If you already run a pre-commit baseline with CI enforcement, a syntax check with apparmor_parser -Q in the pipeline stops a broken profile ever reaching a host.

Read the denials before you enforce

While the profile is still in complain mode, every would-be violation is logged. On a system with auditd the denials land in /var/log/audit/audit.log; without it, they go to the kernel ring buffer and the journal. A denial reads like this.

# Kernel ring buffer, or: journalctl -k | grep apparmor
sudo dmesg | grep DENIED

audit: type=1400 audit(1721650000.123:45): apparmor="DENIED" \
  operation="open" profile="reportd" name="/etc/shadow" pid=1234 \
  comm="reportd" requested_mask="r" denied_mask="r" fsuid=1000 ouid=0

That single line is the case for the whole exercise. Something running as the service tried to read /etc/shadow, and the profile said no. In complain mode it is a warning; in enforce mode it is a wall. Each denial is a decision: is this a legitimate access the learning window missed, in which case add a narrow rule, or is it something the service has no business doing, in which case leave it denied and note why. The tool for turning the log back into rules is aa-logprof, which walks each event and lets you allow, deny, or glob it.

# Replay the logged events and refine the profile interactively.
sudo aa-logprof

# For each event: (A)llow, (D)eny, (G)lob to widen the path,
# or (N)ew to name a tighter rule. Save when done.

Resist the reflex to allow everything the log shows just to make the noise stop. A profile that permits every access the application ever attempted is a profile that permits the exploit too. The point is the paths you deliberately leave out. The same discipline applies to how the service holds its secrets: a profile that denies read access to everything but the service’s own key material pairs well with keys that are hashed, scoped and rotated rather than sitting in a world-readable file the profile then has to allow.

Switch to enforce and verify it loaded

Once the denials are quiet under a representative workload, switch the profile to enforce. You can drop the flags=(complain) from the file, or flip it with aa-enforce; either way, reload the profile into the kernel and confirm the state. Do not assume it took.

# Move the profile from complain to enforce.
sudo aa-enforce /etc/apparmor.d/usr.local.bin.reportd

# After any manual edit, reload it into the kernel.
sudo apparmor_parser -r /etc/apparmor.d/usr.local.bin.reportd

# Verify: reportd should appear under "profiles in enforce mode".
sudo aa-status

aa-status is the check that matters. It lists every loaded profile and whether it is in enforce or complain mode, and it tells you which running processes are confined by a profile and which are unconfined. A profile that exists in /etc/apparmor.d but is not loaded, or is loaded in complain, protects nothing. On a regulated estate this is exactly the kind of control whose operation a supervisor will ask you to evidence, not just its existence, which is the same distinction that separates a healthy incident metric from a decorative one when you instrument the DORA four.

If the service has seasonal or rarely-hit code paths, leave the profile in complain mode for a week in production, watch the denials, then enforce. The failure you are guarding against is the one where an attacker turns a bug in one service into read access to the rest of the box. A confined service turns that into a logged denial and a dead end. That is the entire return on an afternoon’s work, and you collect it on the worst day, not the average one.

Free interactive tool

Website compliance checklist

What your site has to do, based on what it actually does

Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.

Free interactive tool

Interactive deadline calculator

Check which regulations apply to you and when

Regulation across the EU, UK, US and Asia-Pacific has moved considerably in the past eighteen months, and several headline dates have shifted more than once. Twelve questions, about three minutes.

Results are shown on screen — no email required. A dated summary is available to download, and can be sent on if that's more useful. What we do with your answers.

Most technology problems are not technology problems. They are control problems.

The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.

Full Governance by Sixteen Pillars

Govern your business. Prove your compliance.

A board assurance cockpit for EU-regulated financial firms — tamper-evident, hash-chained proof of governance across DORA, GDPR, NIS2, ISO 27001, the EU AI Act and MiCA. In development.

See what's coming