AppArmor
What Is AppArmor?
AppArmor (Application Armor) is a Linux kernel security module that provides Mandatory Access Control (MAC). Unlike traditional Discretionary Access Control (DAC) where file owners set permissions, AppArmor enforces security policies defined by the system administrator that no process can override, regardless of its user privileges.
AppArmor confines programs to a limited set of resources by associating a security profile with each program. These profiles can restrict:
- File access (read, write, execute, link, lock)
- Network access (TCP, UDP, raw sockets)
- Linux capabilities (cap_net_raw, cap_sys_admin, etc.)
- Signal sending between processes
- Mount operations
- Ptrace (process tracing/debugging)
AppArmor vs SELinux
AppArmor and SELinux are both MAC systems, but they differ fundamentally:
- AppArmor uses path-based access control -- profiles reference file paths
- SELinux uses label-based access control -- every file, process, and port has a security label
- AppArmor is generally easier to write profiles for and is the default on Ubuntu/Debian
- SELinux is the default on RHEL/CentOS/Fedora
- The CKS exam focuses on AppArmor, not SELinux
How AppArmor Works
AppArmor Profile Modes
AppArmor operates in three modes:
| Mode | Behavior | Use Case |
|---|---|---|
| Enforce | Violations are blocked and logged | Production -- active security enforcement |
| Complain | Violations are logged but allowed | Development/testing -- discover what a profile needs |
| Unconfined | No restrictions applied | Default for processes without a profile |
Checking AppArmor Status
# Check if AppArmor is enabled
sudo aa-status
# Sample output shows loaded profiles and their modes
# 37 profiles are loaded.
# 19 profiles are in enforce mode.
# /usr/sbin/ntpd
# docker-default
# 18 profiles are in complain mode.
# /usr/bin/evince
# 4 processes have profiles defined.
# 4 processes are in enforce mode.Key Commands for the Exam
# Check AppArmor status and loaded profiles
sudo aa-status
# Load a profile in enforce mode
sudo apparmor_parser /etc/apparmor.d/my-profile
# Load a profile in complain mode
sudo apparmor_parser -C /etc/apparmor.d/my-profile
# Remove/unload a profile
sudo apparmor_parser -R /etc/apparmor.d/my-profile
# Reload a profile (after editing)
sudo apparmor_parser -r /etc/apparmor.d/my-profileAppArmor Profile Syntax
An AppArmor profile defines what a program is allowed to do. Everything not explicitly allowed is denied by default (deny-by-default model).
Basic Profile Structure
#include <tunables/global>
profile my-custom-profile flags=(attach_disconnected) {
#include <abstractions/base>
# File rules
/etc/hostname r, # Read /etc/hostname
/var/log/app/** rw, # Read/write anything under /var/log/app/
/tmp/** rw, # Read/write in /tmp
/usr/bin/python3 ix, # Execute python3, inheriting this profile
# Deny rules (explicit)
deny /etc/shadow r, # Explicitly deny reading shadow
deny /proc/sysrq-trigger w, # Deny writing to sysrq
# Network rules
network tcp, # Allow TCP connections
deny network udp, # Deny UDP
# Capability rules
capability net_bind_service, # Allow binding to privileged ports
deny capability sys_admin, # Deny sys_admin capability
# Signal rules
signal (send) set=(term, kill) peer=unconfined,
}File Permission Flags
| Flag | Permission |
|---|---|
r | Read |
w | Write |
a | Append |
x | Execute |
ix | Execute, inherit current profile |
px | Execute under a specific profile |
ux | Execute unconfined |
m | Memory map executable |
l | Link |
k | Lock |
Loading Profiles on Nodes
Before you can use an AppArmor profile with a Kubernetes pod, the profile must be loaded on every node where the pod might run.
Critical Exam Point
AppArmor profiles are node-level configurations. If a profile is not loaded on the node where a pod is scheduled, the pod will fail to start. In the exam, you may need to SSH into a node to load a profile.
Step 1: Create the Profile File
# SSH into the node
ssh node01
# Create the profile
sudo tee /etc/apparmor.d/k8s-deny-write << 'EOF'
#include <tunables/global>
profile k8s-deny-write flags=(attach_disconnected) {
#include <abstractions/base>
file,
deny /** w,
}
EOFStep 2: Load the Profile
# Load in enforce mode
sudo apparmor_parser /etc/apparmor.d/k8s-deny-write
# Verify it's loaded
sudo aa-status | grep k8s-deny-write
# Output: k8s-deny-writeStep 3: Verify the Profile
# List all loaded profiles
sudo aa-status
# Check specific profile
sudo cat /sys/kernel/security/apparmor/profiles | grep k8s-deny-write
# Output: k8s-deny-write (enforce)Applying AppArmor to Pods
Kubernetes supports AppArmor through the securityContext field at the container level using the appArmorProfile field (Kubernetes v1.30+), or via annotations for older versions.
Method 1: SecurityContext (Kubernetes v1.30+)
apiVersion: v1
kind: Pod
metadata:
name: apparmor-pod
spec:
containers:
- name: app
image: nginx:1.27
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: k8s-deny-writeThe type field accepts three values:
| Type | Description |
|---|---|
RuntimeDefault | Uses the container runtime's default profile |
Localhost | Uses a profile loaded on the node (must specify localhostProfile) |
Unconfined | No AppArmor profile applied |
Method 2: Annotations (Legacy, pre-v1.30)
apiVersion: v1
kind: Pod
metadata:
name: apparmor-pod
annotations:
container.apparmor.security.beta.kubernetes.io/app: localhost/k8s-deny-write
spec:
containers:
- name: app
image: nginx:1.27Annotation Format
The annotation key format is:
container.apparmor.security.beta.kubernetes.io/<container-name>: <profile-ref>Where <profile-ref> is one of:
runtime/default-- use the runtime default profilelocalhost/<profile-name>-- use a profile loaded on the nodeunconfined-- no profile applied
Common AppArmor Profiles for Containers
Profile: Deny All Writes
This profile prevents the container from writing to any path on the filesystem.
#include <tunables/global>
profile k8s-deny-write flags=(attach_disconnected) {
#include <abstractions/base>
# Allow all file reads
file,
# Deny all writes
deny /** w,
}Profile: Deny Network Access
This profile prevents the container from making any network connections.
#include <tunables/global>
profile k8s-deny-network flags=(attach_disconnected) {
#include <abstractions/base>
# Allow file access
file,
# Deny all networking
deny network,
}Profile: Restrict to Read-Only with Specific Writes
#include <tunables/global>
profile k8s-restricted flags=(attach_disconnected) {
#include <abstractions/base>
# Allow reading all files
/** r,
# Allow writes only to specific paths
/tmp/** rw,
/var/run/** rw,
/dev/null rw,
/dev/zero r,
# Allow executing specific binaries
/usr/bin/** ix,
/bin/** ix,
# Allow networking
network tcp,
network udp,
# Deny everything else implicitly
}Profile: Container with Logging Only
#include <tunables/global>
profile k8s-web-app flags=(attach_disconnected) {
#include <abstractions/base>
# Allow file reads
/** r,
# Allow writes to log directory and tmp
/var/log/** rw,
/tmp/** rw,
/dev/null rw,
/proc/** r,
# Allow network (web server needs it)
network tcp,
# Deny dangerous operations
deny capability sys_admin,
deny capability sys_ptrace,
deny /etc/shadow r,
deny /proc/sysrq-trigger w,
}Full Example: AppArmor-Protected Pod
Here is a complete example that creates a profile, loads it, and runs a pod using it.
Step 1: Create and Load the Profile on the Node
# SSH to the node
ssh node01
# Create the profile
cat <<'EOF' | sudo tee /etc/apparmor.d/k8s-nginx-readonly
#include <tunables/global>
profile k8s-nginx-readonly flags=(attach_disconnected) {
#include <abstractions/base>
# Allow file reads everywhere
/** r,
# Allow writes only to specific nginx paths
/var/cache/nginx/** rw,
/var/run/nginx.pid rw,
/tmp/** rw,
/dev/null rw,
/proc/** r,
# Allow network for serving
network tcp,
# Allow required capabilities
capability net_bind_service,
capability setuid,
capability setgid,
capability dac_override,
# Deny dangerous operations
deny capability sys_admin,
deny /etc/shadow r,
}
EOF
# Load the profile
sudo apparmor_parser /etc/apparmor.d/k8s-nginx-readonly
# Verify
sudo aa-status | grep k8s-nginx-readonlyStep 2: Create the Pod
apiVersion: v1
kind: Pod
metadata:
name: nginx-hardened
labels:
app: nginx
spec:
# Ensure it lands on the node with the profile
nodeName: node01
containers:
- name: nginx
image: nginx:1.27
ports:
- containerPort: 80
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: k8s-nginx-readonly
resources:
limits:
memory: "128Mi"
cpu: "250m"Step 3: Apply and Verify
# Apply the pod
kubectl apply -f nginx-hardened.yaml
# Check pod status
kubectl get pod nginx-hardened
# Verify AppArmor is enforced
kubectl exec nginx-hardened -- cat /proc/1/attr/current
# Expected output: k8s-nginx-readonly (enforce)
# Test that writes are blocked
kubectl exec nginx-hardened -- touch /etc/test-file
# Expected: Permission deniedDebugging AppArmor Issues
Common Problems and Solutions
Pod Stuck in "Blocked" or CrashLoopBackOff
Symptom: Pod fails to start with events mentioning AppArmor.
Check: Ensure the profile is loaded on the correct node.
# SSH to the node and check
ssh node01
sudo aa-status | grep <profile-name>
# If not loaded, load it
sudo apparmor_parser /etc/apparmor.d/<profile-name>Application Fails Due to Profile Too Restrictive
Symptom: Application starts but crashes or behaves incorrectly.
Solution: Switch to complain mode to see what's being blocked.
# Switch profile to complain mode
sudo aa-complain /etc/apparmor.d/<profile-name>
# Check audit log for denials
sudo dmesg | grep DENIED
# or
sudo journalctl -k | grep DENIED
# After fixing, switch back to enforce
sudo aa-enforce /etc/apparmor.d/<profile-name>Profile Name Mismatch
Symptom: Pod event says "AppArmor profile not found".
Check: The profile name in the pod spec must match the profile name inside the file (after the profile keyword), NOT the filename.
# Check what the profile is named
sudo aa-status | grep <expected-name>
# The profile name is defined inside the file:
# profile k8s-deny-write flags=(attach_disconnected) {
# ^^^^^^^^^^^^^^^^^^ THIS is the profile nameChecking Audit Logs
# View AppArmor denials in kernel messages
sudo dmesg | grep -i apparmor
# View in journalctl
sudo journalctl -k | grep -i "apparmor.*DENIED"
# View in audit log (if auditd is running)
sudo ausearch -m avc -ts recent
# Example denial log:
# [ 234.567890] audit: type=1400 audit(1234567890.123:456):
# apparmor="DENIED" operation="open" profile="k8s-deny-write"
# name="/etc/test-file" pid=1234 comm="touch" requested_mask="w"
# denied_mask="w" fsuid=0 ouid=0AppArmor Enforcement Flow in Kubernetes
Quick Reference
Exam Speed Reference
# Check AppArmor status
sudo aa-status
# Load profile
sudo apparmor_parser /etc/apparmor.d/<profile>
# Load in complain mode
sudo apparmor_parser -C /etc/apparmor.d/<profile>
# Reload profile
sudo apparmor_parser -r /etc/apparmor.d/<profile>
# Remove profile
sudo apparmor_parser -R /etc/apparmor.d/<profile>
# Check what profile a container is using
kubectl exec <pod> -- cat /proc/1/attr/current
# Check pod events for AppArmor errors
kubectl describe pod <pod> | grep -i apparmorPod spec (v1.30+):
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: <profile-name>Pod annotation (legacy):
annotations:
container.apparmor.security.beta.kubernetes.io/<container>: localhost/<profile>Key Exam Takeaways
- Profiles must be loaded on the node before pods can use them
- The profile name is defined inside the file, not the filename
- Use
apparmor_parser(no flags) to load in enforce mode,-Cfor complain - In pod specs, use
Localhosttype withlocalhostProfilefor custom profiles - Always verify with
aa-statusafter loading a profile - If a profile is too restrictive, switch to complain mode and check
dmesgforDENIEDentries