Shrinking a profile's attack surface with deny rules for shell execution and unneeded setuid binaries, trimming overly broad abstractions, and running a disciplined complain-to-enforce cycle. Then the workflow for updating profiles when an application version bumps: detecting regressions, comparing with diff, and testing before returning to enforce.

In episode 11 you secured desktops and system services: profiles for desktop applications, snap and flatpak integration, even systemd units. Now we enter the next, often-forgotten phase: shrinking the attack surface. A successfully loaded profile isn't the end of the story — the question is whether that profile genuinely shrinks the attack surface, or is so loose it's nearly equivalent to being unconfined.
Many distro built-in profiles are deliberately loose, because they have to serve every user and every scenario. Our job as operators is to narrow them: forbid unneeded executions, close privilege escalation paths, trim abstractions, and make sure every application change doesn't make the policy stale. We do all of that through a disciplined complain-to-enforce cycle.
Think of a profile as the list of doors that may be opened inside a building. Every additional allow rule is one extra door that could someday be broken down. Conversely, something never allowed in the first place has no door at all — nothing can be exploited from a door that doesn't exist.
That's the core of attack surface reduction: changing the default assumption. Many operators focus so hard on adding allow rules until the application "runs", then stop there. In fact the real work begins afterward: removing grants that are never used. AppArmor helps because default deny applies at the profile level — access not written in the policy is denied. Unfortunately, abstractions and generic rules often open up many things at once, so default deny loses its meaning. In this episode we close them one by one.
Daemons like Nginx or Postgres never need an interactive shell, a compiler, or an interpreter. Yet a remote code execution bug usually rests on the ability to execute additional programs — and the shell is the first step. By forbidding interpreter execution, we force the attacker to work only with programs already available, which is far harder.
deny /bin/bash x,
deny /bin/dash x,
deny /bin/sh x,
deny /usr/bin/python3 x,
deny /usr/bin/perl x,The x here is the execution access you already know from episode 4. A deny rule is absolute: deny is evaluated first and wins over allow. That means even if an abstraction grants x on /bin/bash, the deny rule above still blocks it.
Tip
When writing deny rules, remember that symlink paths are also evaluated. On usrmerge systems, /bin/sh is a symlink to /usr/bin/dash — block both paths to leave no gap.
From an attacker's point of view, privilege escalation is the second goal after achieving code execution. setuid binaries like sudo, su, and pkexec are its primary tools: run from an ordinary process, but running with root privileges. A web daemon that can execute sudo is a disaster waiting to happen.
deny /usr/bin/sudo x,
deny /usr/bin/su x,
deny /usr/bin/pkexec x,
deny /usr/bin/passwd x,
deny /usr/lib/openssh/ssh-keysign x,The reason isn't merely "a daemon shouldn't use sudo", but "a daemon doesn't need any access to privilege-raising mechanisms at all". With the denies above, even if an attacker manages to execute code inside the process, they can't call sudo to ask for root — the escalation path is closed off directly.
Abstractions are the ready-made rule packages we discussed in episode 8. The problem: abstractions are designed to be generic. abstractions/base is almost always needed, but abstractions like abstractions/nameservice, abstractions/dbus, or abstractions/openssl carry many grants your application may never use. Every unused grant line is an unintended attack surface.
A good habit: audit every #include in a profile. Ask two questions — is this abstraction genuinely needed, and can it be replaced with narrower, direct rules? If an application only needs DNS resolution and config parsing, maybe abstractions/base alone is enough. Don't be afraid to experiment: remove one include, run the application in complain mode, and see what actually happens.
If several applications share the same needs, create a minimal custom abstraction in /etc/apparmor.d/abstractions/ instead of letting every application pull in a large, irrelevant abstraction.
# /etc/apparmor.d/abstractions/myapp-common
#include <tunables/global>
owner @{HOME}/.config/myapp/ r,
owner @{HOME}/.cache/myapp/ rw,
@{PROC}/[0-9]*/fd/ r,There's no better way to find unused grants than observing real behavior. That's the point of the complain-to-enforce cycle: complain lets the application run while recording every denial, while enforce actually blocks. By comparing what the application "asks for" during complain with what it truly needs after testing, you can remove grants with confidence.
sudo aa-complain /usr/sbin/nginx
sudo systemctl reload nginx
sudo journalctl -k --grep=apparmor
sudo aa-logprof
sudo aa-enforce /usr/sbin/nginxThe sequence above shows a complete cycle:
aa-complain.journalctl -k --grep=apparmor or the audit log.aa-logprof.aa-enforce.Warning
A profile left in complain mode in production is a security lie: every denial is recorded but nothing is blocked. Make it policy that complain mode only lasts through a testing window, and always ends with enforce.
A profile is a contract between the application and the system. When the application version bumps, that contract can change: the new binary reads new files, uses new libraries, or demands new capabilities. If the profile doesn't follow, regression occurs: new denials flood the logs in enforce mode, or the application silently loses function in complain mode.
A disciplined workflow saves you from both:
sudo cp -a /etc/apparmor.d/usr.sbin.nginx /etc/apparmor.d/usr.sbin.nginx.bak
sudo apt upgrade nginx
diff -u /etc/apparmor.d/usr.sbin.nginx.bak /etc/apparmor.d/usr.sbin.nginxBacking up the profile before the upgrade gives you a baseline to compare. After the upgrade, diff -u shows the changes the distro introduced — for example a new binary path or a new include. Don't just copy the result; read it line by line and ask whether each change is relevant to your configuration.
After the upgrade, test in complain mode with workloads representing the core function: requests succeed, TLS works, reload doesn't error, logs rotate. Only when everything is green do you return to enforce. The regression types you need to recognize:
Tip
Make profiles part of the versioned change, not an artifact detached from the application. Store profiles in git alongside the application config, and record the matching application version. This pays off hugely when you review hardening decisions months later.
In this episode you've shrunk the attack surface from three directions: forbidding shell and interpreter execution with deny rules, closing escalation paths through setuid binaries, and trimming abstractions down to only what's needed. You also mastered the complain-to-enforce cycle as a method for testing and narrowing policy, plus an application update workflow that catches profile regressions before they become production problems.
The core takeaways:
In episode 13 we shift focus from "what must not be executed" to "what data must not be read": securing sensitive paths like /etc/shadow, ~/.ssh directories, and key stores, and making sure daemons that touch sensitive data can't write where they shouldn't. See you in episode 13!