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.

ModeBehaviorUse
enforceActually blocks actions the profile doesn’t allow and logs themProduction. Real protection
complainDoesn’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-write on 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, w means writing, and deny means 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-write

Check the result of loading with aa-status (or apparmor_status).

sudo aa-status

In 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 nodeName or 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.

typeMeaning
LocalhostUse a profile pre-loaded on the node. Put the profile name in localhostProfile
RuntimeDefaultApply the container runtime’s default AppArmor profile
UnconfinedLeave 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: NAME equals the annotation value localhost/NAME; type: RuntimeDefault equals runtime/default; and type: Unconfined equals unconfined.

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 1

Reading wasn’t blocked, so the following should work normally.

# Reading is allowed, so this succeeds
kubectl exec hardened-pod -- cat /etc/hostname

If 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/syslog

If 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 the localhostProfile value and the profile name in aa-status differ 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 with aa-status → attach it to the Pod with type: 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 localhostProfile value and the profile name shown in aa-status must 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.appArmorProfile field; before that is the container.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. enforce actually blocks; complain only logs. Use complain for development, enforce for production and exam verification
  • Write the profile in /etc/apparmor.d/ and set permissions with file,, deny /** w,, and per-path rwx rules. deny takes priority over allow
  • Loading and confirming. Load it onto the node with apparmor_parser -r and confirm with aa-status that the profile name appears in the enforce list
  • Pod application. 1.30+ uses securityContext.appArmorProfile (type: Localhost + localhostProfile); earlier uses the container.apparmor.security.beta.kubernetes.io/<container> annotation. The types are Localhost, RuntimeDefault, and Unconfined
  • Verification. Confirm the block and the allow directly with exec, and pinpoint what was blocked with the DENIED log in the node’s dmesg and 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.

X