Certified Kubernetes Security Specialist (CKS) #6: AppArmor profiles (System Hardening)
If the CKA series had you learning cluster operations, and the first five posts of this CKS series covered network isolation and cluster hardening, the domain now shifts. System Hardening confines containers not above Kubernetes but at the Linux kernel level of the node itself. Its first tool is AppArmor.
AppArmor is a Linux security module that allows the file access and capabilities a container invokes only as far as a profile spells out. Even if a container is compromised, the profile keeps it from reaching beyond the paths it has restricted — it is the last wall that confines the damage. In this post we’ll write a profile ourselves, load it onto a node, attach it to a Pod, and confirm that it actually blocks.
What AppArmor is #
Linux’s default access control is DAC (Discretionary Access Control), based on a file’s owner, group, and permission bits. The file owner can change permissions at will, so once a process gains a privilege it can use the full scope of that privilege. When a container runs as root, there’s a lot DAC alone can’t stop.
AppArmor layers MAC (Mandatory Access Control) on top. MAC enforces what a process is allowed to do through a policy the administrator sets, and the process itself can’t loosen that policy. AppArmor’s unit of policy is exactly the profile. A profile spells out how far a given executable can go in each of these:
- Which file paths it can read (
r), write (w), and execute (x) - Which Linux capabilities it may hold
- Whether it can use networking, mounts, signals, and so on
When you attach an AppArmor profile to a container, the moment a process inside that container touches a file or capability outside the profile’s scope, the kernel denies it. The node’s kernel blocks a compromised container’s attempts to read or write host files, which has the effect of confining the damage inside the container.
AppArmor is a path-based MAC, so file-path rules are easy to write. SELinux, in the same MAC family, is label-based and its rules are more complex. CKS deals with AppArmor, and exam nodes are usually distributions where AppArmor is enabled by default.
The two modes of a profile #
An AppArmor profile operates in one of two modes, depending on how the same rules are applied.
| Mode | Behavior | Use |
|---|---|---|
enforce | Actually blocks actions the profile doesn’t allow and logs them | Production. Real protection |
complain | Doesn’t block; only logs the violating actions (audit) | Profile development and tuning |
complain mode is the stage where you keep the application working normally while gathering logs of which accesses occur. The usual flow is to look at those logs, fill in the rules you need, then switch to enforce. In the CKS exam, tasks that require you to confirm actual blocking mostly call for enforce mode.
Writing a profile #
A profile lives as a text file under the node’s /etc/apparmor.d/. As an example, here’s a simple profile that keeps a container from writing almost anywhere except /.
# /etc/apparmor.d/k8s-deny-write
#include <tunables/global>
profile k8s-deny-write flags=(attach_disconnected) {
#include <abstractions/base>
# Allow reading all files
file,
# Deny every attempt to write to disk
deny /** w,
}Let me point out the key syntax.
profile k8s-deny-writeon the first line is the profile name. You use this name as-is when attaching it to a Pod. The file name and the profile name can differ, but keeping them aligned is safer to avoid confusion.flags=(attach_disconnected)is a flag that helps the profile apply correctly when a path looks disconnected in a container environment because of the mount namespace. It’s commonly added to profiles meant for Kubernetes containers.#include <abstractions/base>is a bundle that gathers up in advance the common accesses (loading libraries and the like) a process needs to run normally.file,is a rule that allows all file access for now.deny /** w,is the heart of this profile./**means all sub-paths,wmeans writing, anddenymeans denial. In other words, it forbids writing anywhere.
If you want to narrow it further, spell out permissions per path. The following allows reading only specific directories and blocks writing elsewhere.
profile k8s-restrict flags=(attach_disconnected) {
#include <abstractions/base>
# Allow read/execute only on the application directory
/app/** r,
/usr/bin/** rix,
# Allow writing only to the temp directory
/tmp/** rw,
# Explicitly deny access to sensitive paths
deny /etc/shadow r,
deny /proc/sysrq-trigger rwx,
}Rules aren’t evaluated top to bottom; they’re resolved by combining permissions and denials, and deny takes priority over any allow. That’s why the common approach is to allow broadly and then lock down only the dangerous paths with deny.
Loading the profile onto a node #
Simply keeping the profile as a file doesn’t make it work. You have to load it into the kernel. On a node where AppArmor is installed, you load it with apparmor_parser.
# Load (or update) the profile into the kernel. -r is replace
sudo apparmor_parser -r /etc/apparmor.d/k8s-deny-write-r (replace) overwrites with the new content even if a profile of the same name is already loaded. So you use the same command when you reload after editing the profile. The default mode is enforce; to load in complain, use aa-complain.
# Load in complain mode (doesn't block, only logs)
sudo aa-complain /etc/apparmor.d/k8s-deny-write
# Back to enforce mode
sudo aa-enforce /etc/apparmor.d/k8s-deny-writeCheck the result of loading with aa-status (or apparmor_status).
sudo aa-statusIn the output, confirm the following.
apparmor module is loaded.
42 profiles are loaded.
38 profiles are in enforce mode.
...
k8s-deny-write
4 profiles are in complain mode.
...If k8s-deny-write shows up in the enforce list, it loaded correctly onto the node. In the exam, you judge a successful load by whether the profile name appears exactly in this list, so you must get the spelling of the name right.
One important pitfall. The profile must be loaded in advance on the node where the Pod will be scheduled. In a multi-node cluster, you have to match which node you loaded it onto with which node the Pod goes to. The exam usually guides you to specify
nodeNameor use a single worker node, but with multiple nodes it’s safer to load it onto every candidate node or pin the scheduling.
Applying the profile to a Pod #
Once the profile is loaded onto the node, you now attach that profile to the Pod’s container. The approach splits in two depending on the Kubernetes version.
1.30 and later: securityContext.appArmorProfile #
From Kubernetes 1.30, the AppArmor setting became a proper field. You specify it with appArmorProfile under securityContext. It can be used at both the Pod level and the container level, and the container level overrides the Pod level.
apiVersion: v1
kind: Pod
metadata:
name: hardened-pod
spec:
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: k8s-deny-write
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "sleep 3600"]The meaning changes with type.
| type | Meaning |
|---|---|
Localhost | Use a profile pre-loaded on the node. Put the profile name in localhostProfile |
RuntimeDefault | Apply the container runtime’s default AppArmor profile |
Unconfined | Leave it unrestricted, with no AppArmor applied |
When attaching a profile you wrote yourself, the key is to put the name of the profile loaded on the node into localhostProfile with type: Localhost. The name must exactly match the one in the aa-status list; if it differs, the Pod fails to create.
1.29 and earlier: annotation #
Before 1.30, AppArmor was specified with an annotation. You need to know the format for when the exam cluster version is lower or when you’re handling existing manifests. The key includes the container name.
apiVersion: v1
kind: Pod
metadata:
name: hardened-pod
annotations:
container.apparmor.security.beta.kubernetes.io/app: localhost/k8s-deny-write
spec:
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "sleep 3600"]The annotation key takes the form container.apparmor.security.beta.kubernetes.io/<container-name>, and the value takes the form localhost/<profile-name>. In the example above the container name is app, so the key ends in /app, and the value points to the node-loaded k8s-deny-write with the localhost/ prefix. The types you write in the value are the same as the field approach: localhost/<name>, runtime/default, and unconfined.
Memorizing how the two approaches map saves you regardless of version. The field’s
type: Localhost+localhostProfile: NAMEequals the annotation valuelocalhost/NAME;type: RuntimeDefaultequalsruntime/default; andtype: Unconfinedequalsunconfined.
Verifying the profile blocks #
Attaching it isn’t the end. The habit of confirming with exec that the profile actually blocks is what protects your score in the exam. Let me confirm with a Pod that has the k8s-deny-write profile (write forbidden anywhere) above attached.
# Create the Pod
kubectl apply -f hardened-pod.yaml
# Attempt a write from inside the container
kubectl exec hardened-pod -- sh -c 'echo test > /tmp/x'If the profile applied correctly, the write is denied and you get an error similar to this.
sh: can't create /tmp/x: Permission denied
command terminated with exit code 1Reading wasn’t blocked, so the following should work normally.
# Reading is allowed, so this succeeds
kubectl exec hardened-pod -- cat /etc/hostnameIf writing is blocked and reading works, the profile is behaving as intended. When a block happens, a record is also left in the node’s kernel log, so you can confirm it on the node with the following.
# Check AppArmor deny logs on the node
sudo dmesg | grep -i apparmor
# or
sudo grep -i 'apparmor.*DENIED' /var/log/syslogIf you see DENIED in the log along with the profile name and the blocked path, you can pinpoint exactly which action was blocked. Conversely, if the profile is too tight and even the application’s normal operation got blocked, this log tells you which rule you need to loosen.
If you’ve attached a profile but no blocking happens, it’s almost always one of two things. First, the profile wasn’t loaded onto that node. Confirm with
aa-status. Second, the name is off. If thelocalhostProfilevalue and the profile name inaa-statusdiffer by even one character, it isn’t applied.
Exam points #
In CKS’s System Hardening domain, AppArmor is a near-constant regular. Getting the following into your hands lets you solve it without mistakes.
- Memorize the flow. Write the profile → load it onto the node with
apparmor_parser -r→ confirm the load withaa-status→ attach it to the Pod withtype: Localhost+localhostProfile(or an annotation) → verify the block with exec. These five steps are the archetype of one task. - The profile has to be on the node for the Pod to come up. The most common failure is skipping the node load and only editing the Pod manifest. Load first, Pod second.
- Get the name exactly right. The
localhostProfilevalue and the profile name shown inaa-statusmust match exactly. If the spelling is off, the Pod gets blocked at the creation stage. - Know both version-specific approaches. 1.30+ is the
securityContext.appArmorProfilefield; before that is thecontainer.apparmor.security.beta.kubernetes.io/<container>annotation. Check the exam cluster version first. - Distinguish the three types.
Localhost(a profile you made yourself),RuntimeDefault(the runtime default),Unconfined(no restriction). Pick by looking at what the question asks for. - Go all the way to verification. Confirming a blocked action and a working action once each with exec prevents the accident of just attaching it and moving on while it wasn’t actually applied.
Wrap-up #
What this post locked in:
- AppArmor is Linux MAC. Layered on top of DAC, it enforces via a profile the file paths and capabilities a process may access, and the process can’t loosen it itself
- There are two modes.
enforceactually blocks;complainonly logs. Use complain for development, enforce for production and exam verification - Write the profile in
/etc/apparmor.d/and set permissions withfile,,deny /** w,, and per-pathrwxrules.denytakes priority over allow - Loading and confirming. Load it onto the node with
apparmor_parser -rand confirm withaa-statusthat the profile name appears in the enforce list - Pod application. 1.30+ uses
securityContext.appArmorProfile(type: Localhost+localhostProfile); earlier uses thecontainer.apparmor.security.beta.kubernetes.io/<container>annotation. The types areLocalhost,RuntimeDefault, andUnconfined - Verification. Confirm the block and the allow directly with exec, and pinpoint what was blocked with the
DENIEDlog in the node’sdmesgand syslog
Next: seccomp profiles #
If AppArmor blocks files and capabilities on a path basis, the counterpart in the same System Hardening domain is seccomp, which blocks the system calls themselves.
In #7 seccomp profiles, we’ll build it ourselves and cover how to narrow the system calls a container can invoke down to a whitelist, the meaning of the RuntimeDefault profile, the procedure of placing a custom seccomp profile (JSON) on the node and attaching it with securityContext.seccompProfile, actions like SCMP_ACT_ERRNO and SCMP_ACT_ALLOW, and the flow of finding a blocked system call and narrowing the profile.