Skip to main content

Security Modules & Capabilities

Linux security is multi-layered. Understanding these mechanisms is essential for infrastructure engineers building secure container platforms and debugging permission issues. Think of Linux security as a series of checkpoints a request must pass through — each layer can say “no” independently, and the request only succeeds if every layer says “yes.” This defense-in-depth approach means that even if one layer is misconfigured or exploited, the others still provide protection.
Interview Frequency: High (especially for container/cloud roles)
Key Topics: LSM framework, capabilities, seccomp-bpf, SELinux/AppArmor
Time to Master: 12-14 hours

Linux Security Architecture

The ordering matters. Seccomp runs first because it is the cheapest check (a BPF program evaluating syscall numbers), and rejecting a syscall at this stage avoids the cost of all subsequent checks. Capabilities and DAC run next because they are fast lookups. LSM hooks run last because they can involve complex policy evaluation (especially SELinux, which consults an in-kernel policy database).
A senior engineer would say: “Security is not a feature you bolt on at the end. Each layer addresses a different threat model: seccomp limits the kernel’s attack surface, capabilities implement least privilege, DAC controls data access, and LSM enforces organizational policy. If someone asks you to ‘just disable SELinux,’ they are asking you to remove one of those layers — and you should understand which threats you are accepting before you do.”

Capabilities: Dividing Root Power

Traditional Unix has a binary security model: UID 0 (root) can do everything, everyone else is restricted. This means a web server that needs to bind to port 80 must run as root, getting ALL of root’s power including the ability to load kernel modules, read any file, kill any process, and change the system clock. Capabilities break this “all or nothing” model into approximately 40 distinct privileges.

Common Capabilities

The CAP_SYS_ADMIN trap: CAP_SYS_ADMIN is the most commonly requested and most dangerous capability. It controls over 30 different operations including mount(), sethostname(), setns(), pivot_root(), ioctl() on many devices, and more. Granting CAP_SYS_ADMIN to a container is essentially the same as running it as --privileged. If an application claims to need CAP_SYS_ADMIN, push back and find out which specific operation it needs — there may be a narrower capability or an alternative approach.

Capability Sets

Each process has multiple capability sets that interact during permission checks and across execve() boundaries. This is where capabilities get subtle.

Working with Capabilities

Kernel Capability Checks

Understanding how the kernel checks capabilities helps you debug “permission denied” errors. Every privileged operation in the kernel calls capable() or ns_capable() before proceeding.
Debugging capability denials: When you get EACCES or EPERM and cannot figure out why, use strace to find the failing syscall, then search the kernel source for that syscall’s capable() or ns_capable() checks. This tells you exactly which capability is needed. For example: strace -e trace=bind ./myapp 2>&1 | grep EACCES shows the bind() call failing, and the kernel source for inet_bind() shows it needs CAP_NET_BIND_SERVICE.

Linux Security Modules (LSM)

LSM provides a framework of hooks throughout the kernel for mandatory access control. Unlike DAC (where the file owner controls permissions), MAC policies are set by the administrator and cannot be overridden by users, even root. The key mental model: LSM hooks are checkpoints inserted at every security-relevant kernel operation. Each registered security module gets a chance to say “deny” at each checkpoint.

LSM Architecture

Since kernel 5.4, Linux supports “stacking” multiple LSM modules. You can have SELinux + BPF LSM active simultaneously. The order is determined at compile time and boot parameters. Each hook in the chain must approve the operation for it to proceed.

LSM Hooks


SELinux

Type Enforcement security — every subject (process) and object (file, socket, etc.) is labeled with a security context. Policy rules define which types can interact with which other types and how. If there is no explicit “allow” rule, the access is denied. This “default deny” model is what makes SELinux so effective and so frustrating.

SELinux Context

SELinux Policy Rules

The audit2allow trap: It is tempting to run audit2allow -a -M fix && semodule -i fix.pp every time SELinux blocks something. This gradually opens up your security policy until SELinux is technically enabled but not actually protecting anything. A better approach: understand WHY the denial happened. Often the file has the wrong context (fix with restorecon) or a boolean needs to be set (fix with setsebool). Only create custom policy rules when the standard policy genuinely does not cover your use case.

SELinux Modes

The “just disable SELinux” anti-pattern: When SELinux blocks something, the temptation is to run setenforce 0 or add SELINUX=disabled to the config. This is the security equivalent of turning off smoke detectors because they beep. Instead: set the specific domain to permissive (semanage permissive -a httpd_t) to debug that one service without disabling protection for everything else. Then fix the root cause and re-enforce.

SELinux Booleans

Booleans are pre-defined policy switches that enable/disable common configurations without writing custom policy.

AppArmor

Profile-based MAC — simpler than SELinux because it uses pathnames rather than labels. Each confined program has a profile that lists exactly which files, capabilities, and network operations it can use. If it is not in the profile, it is denied.

AppArmor Profiles

AppArmor Modes

AppArmor in Containers

SELinux vs AppArmor for containers: Docker and Kubernetes work with both. SELinux uses MCS (Multi-Category Security) labels to isolate containers from each other — each container gets a unique category pair (e.g., s0:c123,c456) so container A cannot access container B’s files even if both run as the same UID. AppArmor uses per-container profiles to restrict file access and capabilities. In practice, most teams use whichever their distro defaults to: SELinux on RHEL/Fedora/CentOS, AppArmor on Ubuntu/SUSE/Debian.

Seccomp-BPF

Seccomp (Secure Computing) with BPF filters lets you restrict which system calls a process can make. This is your last line of defense against kernel exploits: even if an attacker gets code execution inside your container, they can only invoke the ~300 syscalls that the filter allows, not the ~400+ that the kernel provides.

How Seccomp Works

The filter is attached to a process with prctl(PR_SET_SECCOMP) and is inherited by all child processes (including across execve()). Once attached, it cannot be removed or weakened — a process can only add more restrictive filters on top. This “no weakening” property is what makes seccomp safe against privilege escalation.

Writing Seccomp Filters

TOCTOU warning: Seccomp filters check syscall arguments at the time of the filter evaluation, but the arguments live in userspace memory. A multi-threaded process could change an argument between the seccomp check and the kernel’s use of that argument. For this reason, seccomp filters on pointer arguments (like filenames in open()) are inherently racy. Use LSM (SELinux/AppArmor) for path-based access control, and use seccomp for syscall-number-level filtering.

Docker Seccomp Profile


eBPF LSM

Write custom security policies with eBPF programs that attach to LSM hooks. This gives you the flexibility of custom kernel modules without the stability risk — BPF programs are verified for safety before loading.
When to use BPF LSM vs SELinux/AppArmor: Use SELinux/AppArmor for standard server hardening — they have mature tooling, well-tested policies, and broad community support. Use BPF LSM for dynamic, application-specific policies that need to change at runtime without restarting services. For example, a security team might deploy a BPF LSM program that blocks a specific CVE’s exploitation technique fleet-wide within minutes, without modifying any SELinux policy files or restarting any services.

Container Security Stack

Docker Security Options

Kubernetes Pod Security

Pod Security Standards (PSS): Kubernetes 1.25+ enforces Pod Security Standards at the namespace level. The three levels are: privileged (no restrictions), baseline (blocks known privilege escalations), and restricted (hardened, drops all capabilities, requires non-root, read-only rootfs). Apply restricted to all production namespaces: kubectl label namespace production pod-security.kubernetes.io/enforce=restricted. Most “it worked in dev but not prod” security issues are caused by running baseline in dev and restricted in prod.

Debugging Security Issues

Capability Denied

SELinux Denials

AppArmor Denials

Seccomp Violations

The debugging workflow for “container cannot do X”: (1) Check capabilities: docker inspect | grep Cap. (2) Check seccomp: is the syscall blocked by the profile? dmesg | grep seccomp. (3) Check LSM: ausearch -m avc for SELinux or dmesg | grep apparmor. (4) Check DAC: plain file permissions. The layers are checked in order, and the FIRST denial wins. Start from the outermost layer (seccomp) and work inward.

Interview Questions

Answer:Multiple layers:
  1. User namespace: Map container UID 0 to unprivileged host UID
  1. Capabilities: Drop all, add only needed
  1. No new privileges: Prevent setuid escalation
  1. Seccomp: Filter dangerous syscalls
  2. Read-only rootfs: Prevent persistence
  3. In application code:
Answer:Key difference: SELinux labels objects (files, processes) with security contexts. Policy rules define allowed interactions between types. A file moved to a different directory keeps its SELinux label. AppArmor uses pathnames — profiles define what paths a program can access. A file moved to a different path might gain or lose protection depending on the profile rules.When to use which:
  • SELinux for high-security environments needing fine-grained control (government, finance)
  • AppArmor when simplicity and rapid deployment are preferred
  • Both provide strong security when properly configured
Answer:The problem: Containers share the host kernel. A container could exploit kernel vulnerabilities via syscalls. Every syscall is an entry point into the kernel, and historically, many kernel CVEs are triggered by specific syscall sequences.Seccomp-bpf solution: Filter syscalls before they execute:
  1. Container runtime installs a BPF filter at container start
  2. Every syscall is checked against the filter before entering the kernel
  3. Dangerous syscalls are blocked (e.g., ptrace, mount, kexec_load)
Docker default profile blocks:
  • kexec_load - Replace running kernel (game over if allowed)
  • mount - Mount filesystems (escape container filesystem isolation)
  • ptrace - Debug/trace processes (read other containers’ memory)
  • create_module / init_module - Load kernel modules (arbitrary kernel code)
  • And ~50 more dangerous syscalls
Performance: Very low overhead — the BPF filter runs in kernel space, evaluated in nanoseconds per syscall with no context switches. It is effectively free compared to the cost of the syscall itself.
Answer:Traditional problem:
  • UID 0 = all privileges (approximately 40 distinct powers)
  • Regular user = almost no privileges
  • Programs needing one privilege (bind port 80) got ALL of root’s power
  • A compromised web server running as root could load kernel modules
Capabilities solution: Break root into ~40 discrete privileges:
  • CAP_NET_BIND_SERVICE - Bind to ports below 1024
  • CAP_SYS_ADMIN - Various admin tasks (too broad, avoid this one)
  • CAP_SYS_MODULE - Load kernel modules
  • etc.
Benefits:
  • Least privilege: Grant only what is needed, nothing more
  • Defense in depth: Compromised process has limited blast radius
  • Container isolation: Different containers get different capability sets
  • Auditable: getpcaps shows exactly what a process can do
Example: Web server needs only CAP_NET_BIND_SERVICE, not full root:

Interview Deep-Dive

Strong Answer:
  • I would work through the security layers from outermost to innermost to understand what the attacker could and could not do, then investigate what actually happened.
  • Seccomp (layer 1): If the RuntimeDefault seccomp profile was applied, the attacker cannot call ptrace (cannot debug other containers’ processes), mount (cannot mount the host filesystem), kexec_load (cannot replace the kernel), or init_module (cannot load kernel modules). This eliminates the most common container escape techniques. I would check kubectl get pod -o yaml to verify the seccomp profile was actually applied — if seccompProfile is not set, no seccomp filter was active.
  • Capabilities (layer 2): If drop: ALL was set with only specific capabilities added back, the attacker cannot perform privileged operations even though they may be root inside the container. Without CAP_SYS_ADMIN, they cannot call mount() or setns() to access other namespaces. Without CAP_NET_RAW, they cannot sniff network traffic. I would check the pod spec for the capabilities section and cross-reference with the container runtime’s default capability list (Docker grants 14 by default if you do not specify).
  • Namespace isolation (layer 3): The PID namespace means the attacker sees only their container’s processes. The network namespace means they only see their container’s network stack (though they may be able to reach other pods via the pod network if NetworkPolicies are not in place). The mount namespace means /proc and /sys show the container’s view, not the host’s. User namespaces (if enabled) mean that root inside the container maps to an unprivileged UID on the host.
  • LSM (layer 4): SELinux MCS labels (if enabled) prevent the container from accessing files belonging to other containers. Even if the attacker breaks out of mount namespace isolation, the SELinux label mismatch blocks access. AppArmor profiles restrict file access to paths explicitly listed in the profile.
  • For investigation: I would start with the audit log (ausearch -m avc for SELinux denials, dmesg | grep seccomp for seccomp blocks). These logs tell me what the attacker TRIED to do that was blocked. Then I would examine the container’s filesystem (if not read-only) for dropped tools or modified files. kubectl logs and container runtime logs show the initial compromise vector. For network-level investigation, I would check Cilium/Calico flow logs to see what connections the compromised pod made — did it try to reach the metadata service? The Kubernetes API? Other pods?
Follow-up: What if the pod was running as privileged: true?Follow-up Answer:
  • A privileged container effectively disables ALL security layers: no seccomp filter, all capabilities granted (including CAP_SYS_ADMIN), access to all host devices via /dev, and no LSM confinement. The attacker has essentially root access on the host. They can mount the host filesystem (mount /dev/sda1 /mnt), read any file, load kernel modules, attach to any namespace (nsenter -t 1 -m -u -i -n -p), and compromise every container on the node. This is why privileged: true should NEVER be used in production. The only legitimate use cases are system-level DaemonSets (CNI plugins, node monitoring agents) that genuinely need host access, and even those should be scrutinized for whether they can use specific capabilities instead.
Strong Answer:
  • Multi-tenancy on shared Kubernetes infrastructure requires isolation at every layer: compute, network, storage, and the Kubernetes API itself. I would implement the following:
  • Namespace-level isolation: Each team gets a dedicated Kubernetes namespace. Apply Pod Security Standards at restricted level: kubectl label namespace team-a pod-security.kubernetes.io/enforce=restricted. This forces all pods to run as non-root, drop all capabilities, use read-only root filesystem, and apply the default seccomp profile. Teams that need exceptions go through a review process.
  • Network isolation: Apply default-deny NetworkPolicies in every namespace. By default, pods in team-a’s namespace cannot communicate with pods in team-b’s namespace. Specific cross-namespace communication is explicitly allowed via policy. Use Cilium for L7-aware policies (allow HTTP GET to the API but block POST) and DNS-aware policies (allow connections to api.example.com but not arbitrary IPs).
  • Resource isolation: ResourceQuotas per namespace cap total CPU, memory, and storage. LimitRanges set per-pod defaults and maximums. This prevents one team from consuming all cluster resources. For performance isolation, use dedicated node pools with taints/tolerations for latency-sensitive workloads, preventing noisy neighbors.
  • RBAC (API-level isolation): Each team gets a Kubernetes Role scoped to their namespace. They can create/delete pods and services in their namespace but cannot access other namespaces, nodes, or cluster-level resources. Use ClusterRole bindings sparingly. Audit all RBAC permissions with kubectl auth can-i --list --as=team-a-user.
  • Image security: Enforce signed images with admission controllers (Sigstore/cosign, OPA Gatekeeper). Block latest tag usage. Scan all images for CVEs before allowing deployment. Restrict image sources to approved registries only.
  • Runtime security: Deploy Falco or Tetragon as a DaemonSet for runtime threat detection. Alert on anomalous behavior: unexpected process execution (shell in a web server container), unexpected network connections (outbound to unknown IPs), filesystem modifications in read-only containers, privilege escalation attempts.
  • SELinux/AppArmor: With SELinux, each namespace’s pods get a unique MCS category via seLinuxOptions in the pod security context. Pod A in team-a (s0:c1,c2) cannot access files created by pod B in team-b (s0:c3,c4) even if both run as the same UID and share a persistent volume.
Follow-up: A developer argues that all these restrictions slow down their development workflow. How do you balance security with developer experience?Follow-up Answer:
  • I would create a tiered environment approach. Development namespaces run baseline Pod Security Standards (not restricted), allowing developers to iterate quickly without fighting security restrictions. Staging namespaces run restricted with the same policies as production. CI/CD pipelines automatically deploy to staging and reject promotions to production if the pod spec violates restricted policies. This way, developers discover security issues in staging (where they can fix them at their pace) rather than in production (where it is an emergency). I would also invest in self-service tooling: a Helm chart library with pre-hardened pod security contexts, a policy-as-code repository where teams can request exceptions with justification, and clear documentation explaining WHY each restriction exists and what the secure alternative is. When developers understand that drop: ALL protects their service from being used as a lateral movement pivot after another team’s container is compromised, they become allies rather than adversaries.
Strong Answer:
  • User namespaces create a mapping between UIDs inside the namespace and UIDs outside. When a process has UID 0 inside a user namespace, it has full capabilities WITHIN that namespace, but those capabilities are scoped to the namespace’s resources only. The kernel checks ns_capable() which verifies that the process has the capability in the correct namespace for the operation being attempted.
  • Here is the mechanism: when a rootless container starts, it calls clone(CLONE_NEWUSER) which creates a new user namespace. The process then writes a UID mapping like 0 100000 65536 to /proc/self/uid_map, meaning UID 0 inside maps to UID 100000 outside (an unprivileged user), and UIDs 1-65535 inside map to 100001-165535 outside. Inside the namespace, the process has all capabilities in its effective set. It can call mount(), create device nodes, and perform other privileged operations — but ONLY on resources owned by the namespace.
  • The security boundary is the namespace’s resource scope. CAP_NET_ADMIN inside a user namespace lets you configure the network stack of network namespaces owned by that user namespace, but NOT the host’s network stack. CAP_SYS_ADMIN lets you mount filesystems (with restrictions — only certain fs types like tmpfs, proc, sysfs are allowed), but not mount raw block devices. CAP_MKNOD is restricted — you can create device files but they will not function for accessing actual hardware because the device cgroup (or device filtering in cgroups v2) prevents it.
  • The critical invariant: a user namespace cannot grant privileges that its creator did not have in the parent namespace. If the parent process had no capabilities in the parent namespace, the child’s capabilities (even though they are “all” inside the new namespace) cannot affect anything outside the new namespace’s scope. The bounding set in the parent namespace remains the hard ceiling.
  • Practical security implications for rootless containers: the container’s “root” can install packages, bind to port 80 inside the container’s network namespace, and manage processes — all without any privilege on the host. If the container is compromised, the attacker has UID 100000 on the host (an unprivileged user) and cannot read /etc/shadow, load kernel modules, or affect any other container or the host.
Follow-up: What are the limitations of rootless containers that prevent some workloads from running?Follow-up Answer:
  • Several operations are either impossible or require workarounds in rootless containers. First, network: rootless containers cannot create veth pairs or configure bridge networking directly because those operations require real CAP_NET_ADMIN in the initial namespace. Rootless Docker uses slirp4netns (userspace TCP/IP stack) or pasta for networking, which adds ~10-20% network latency overhead compared to bridge networking. Second, storage: rootless containers cannot use some storage drivers (devicemapper, native overlay on kernels below 5.11). Overlayfs in a user namespace was only supported starting kernel 5.11 with the metacopy and userxattr mount options. Third, cgroups v2 delegation: the systemd cgroup driver supports rootless delegation, but cgroups v1 does not. This means rootless containers on cgroups v1 systems cannot set memory limits on sub-containers. Fourth, certain syscalls like mknod for real devices, mount for block devices, and setxattr for security labels are restricted even inside the user namespace for safety reasons.
Strong Answer:
  • I would use a four-phase approach: discover, build, test, and monitor.
  • Phase 1 — Discover: Run the application with SCMP_ACT_LOG as the default action in the seccomp profile. This allows all syscalls but logs every one. Simultaneously, run the application’s full test suite and exercise all code paths (including error paths, graceful shutdown, log rotation, etc.). Collect the syscall audit logs: grep SECCOMP /var/log/audit/audit.log | awk '{print $NF}' | sort -u gives the complete set of syscalls the application uses. Alternatively, use strace -f -c ./myapp for a summary, or OCI runtime tools like oci-seccomp-bpf-hook which automatically generate seccomp profiles from observed behavior.
  • Phase 2 — Build: Start with an empty allowlist and add only the syscalls discovered in Phase 1. For syscalls with argument-level sensitivity (like clone, which should not be called with CLONE_NEWUSER or CLONE_NEWNS flags from inside a container), add argument filters: SCMP_CMP_MASKED_EQ to check specific flag bits. For the default action, I prefer SCMP_ACT_ERRNO(EPERM) over SCMP_ACT_KILL during rollout because it returns an error rather than killing the process, making it easier to discover missing syscalls. Switch to SCMP_ACT_KILL_PROCESS once the profile is validated.
  • Phase 3 — Test: Deploy the profile in a staging environment with the application under realistic load (not just unit tests — integration tests, performance tests, chaos tests). Monitor for two things: application errors (EPERM in logs indicates a missing syscall in the allowlist) and application functionality (all features work correctly). Run for at least one full application lifecycle including startup, steady state, graceful shutdown, and log rotation. Do not forget to test container restart, OOM recovery, and signal handling.
  • Phase 4 — Monitor: In production, switch the default action to SCMP_ACT_LOG for the first week, which allows blocked syscalls but logs them. Monitor for unexpected syscall attempts — these could be legitimate code paths you missed in testing or could be actual attack attempts. After one week of clean logs, switch to SCMP_ACT_KILL_PROCESS for full enforcement. Keep the monitoring active permanently: any new seccomp log entries after enforcement indicate either a bug in the profile (if the application crashes) or an attack attempt (if the application continues normally).
  • A practical shortcut for most teams: start with Docker’s default profile, which blocks the ~50 most dangerous syscalls. Only build a custom minimal profile for security-critical services or services exposed to untrusted input. The effort of maintaining a minimal profile (updating it with every dependency change) is significant and not always worth the marginal security improvement over the default.
Follow-up: How do you handle seccomp profiles when the application uses dynamic languages (Python, Node.js) that may invoke different syscalls depending on which code path is taken?Follow-up Answer:
  • Dynamic languages are harder to profile because their syscall surface depends on which modules are loaded, which Python C extensions are called, and even which JIT paths the runtime takes. My approach changes in two ways: First, the discovery phase must be longer and more thorough. I would run the application for multiple days in SCMP_ACT_LOG mode under production-like traffic, not just test traffic, to capture rare code paths. I would also parse the application’s dependency tree to identify C extensions (which make direct syscalls) and research their syscall requirements. Second, I would use a slightly broader allowlist than for a static binary — including syscalls that the runtime might use for garbage collection (madvise, mprotect), JIT compilation (mmap with PROT_EXEC), and dynamic module loading (openat, mmap). The profile for a Python application might allow 150 syscalls versus 50 for a Go static binary, but it is still significantly smaller than the full ~400 available, eliminating the most dangerous attack surface.

Summary


Next Steps

  • Namespaces - Container isolation primitives
  • Cgroups - Resource limiting
  • eBPF - Custom security with BPF LSM