Back to Blog
Hardening Guide · OpenShift Confinement

Workload Confinement:
SCC + SELinux

OpenShift confines workloads with two layers, not one: SCC decides what a pod may request at admission, and SELinux decides what the running container may actually touch at the kernel. Weaken either — an anyuid grant, a permissive node — and you have quietly halved the boundary. Here is how they compose, and how to keep both walls up.

Riad DAHMANIk8sec Security ResearchAugust 202615 min readOpenShiftSCCSELinux
Minimalist abstract hero: a trajectory crossing nested boundary rings into a core, representing SCC admission and SELinux runtime confinement layers on OpenShift

Two Questions, Two Layers

"How does OpenShift stop a container from doing something dangerous?" is really two questions with two different answers, enforced by two different subsystems at two different times:

  • What may a pod request? — decided at admission by Security Context Constraints (SCC). Can it be privileged? Run as UID 0? Add CAP_SYS_ADMIN? Mount a hostPath? Share host namespaces?
  • What may a running container touch? — decided at runtime by the kernel via SELinux mandatory access control. Even a container that was allowed to run is still fenced by type enforcement and per-container MCS categories.
Teams conflate these and get burned both ways: they loosen an SCC thinking SELinux will still save them, or they lean on SCC and never notice SELinux was set to permissive. Confinement on OpenShift is the product of both layers — weaken either and the other is doing half the job.

The Two-Layer Model

LAYER 1 · ADMISSION-TIME (what a pod may REQUEST) LAYER 2 · RUNTIME (what it may TOUCH) POD SPEC requests X SCC allow / mutate / deny RUNNING container_t SELinux MAC type enforcement + MCS category blocks host / cross-pod Defense in depth: SCC gates the request; SELinux still confines what a granted container can reach.
SCC answers "may this pod ask to be privileged / run as root / mount the host?" at admission. SELinux answers "now that it is running, what can it actually touch?" at the kernel. You need both.

Layer 1 — SCC: What a Pod May Request

An SCC is an admission-time policy object. Every pod is admitted under exactly one SCC, chosen from those its ServiceAccount is allowed to use (highest-priority wins). The SCC can reject the pod, mutate it (e.g. assign a UID from a range), or allow it. The built-ins span a wide risk range:

SCCGrantsRisk
restricted-v2 (default)non-root, drops ALL caps, no host access, seccomp RuntimeDefaultLow — the goal state
nonroot-v2like restricted but caller picks the (non-zero) UIDLow
anyuidrun as any UID including 0 (root-in-container)Medium — image convenience, real cost
hostaccess / hostmount-anyuidhost namespaces / host path mountsHigh
privilegedeverything — privileged, all caps, host ns, any SELinux contextCritical — node-equivalent
who can run what — the SCC audit that matters most
# list SCCs and their power
oc get scc

# which users/SAs/groups are bound to the dangerous ones?
oc adm policy who-can use scc/privileged
oc adm policy who-can use scc/anyuid

# review a workload's effective SCC (admission annotates it)
oc get pod <pod> -o jsonpath='{.metadata.annotations.openshift\.io/scc}{"\n"}'
The single most common OpenShift weakening: oc adm policy add-scc-to-user privileged -z my-sa to "make the image work". That one command hands a ServiceAccount node-equivalent power. Treat privileged/anyuid grants as break-glass, reviewed and rare.

Layer 2 — SELinux: What a Running Container May Touch

Every container process runs under an SELinux type, normally container_t, and the container's files are labelled container_file_t. Type enforcement means a container_t process simply cannot read host files labelled otherwise — regardless of its UID or Linux capabilities. On top of that, OpenShift assigns each pod a unique MCS category pair (e.g. s0:c123,c456), so container A cannot touch container B's files even though both are container_t.

see the runtime label doing the confining
# the SELinux context OpenShift assigned this pod
oc get pod <pod> -o jsonpath='{.spec.securityContext.seLinuxOptions}{"\n"}'

# on the node: the container process type + MCS categories
ps -eZ | grep container_t          # system_u:system_r:container_t:s0:c123,c456
# host confirms SELinux is actually enforcing (not permissive!)
getenforce                          # must say: Enforcing
spc_t — the escape hatch
"super-privileged container" type spc_t is effectively unconfined by SELinux. Privileged pods and certain host-access SCCs land here — which is exactly why an over-broad SCC removes Layer 2, not just Layer 1.
permissive = off
setenforce 0 or a permissive node turns type-enforcement denials into audit-only — the whole runtime boundary silently collapses.
PROCESS container_t s0:c123,c456 even as UID 0 + all caps SELinux ALLOW · container_file_t its own volumes, matching MCS DENY · host files (etc_t, …) regardless of UID / capabilities DENY · other pod (s0:c789,…) different MCS category pair
SELinux type enforcement + MCS: a container_t process reaches only its own container_file_t files in its category pair — the host and its neighbours are off-limits even to root-in-container.

How They Compose

The two layers multiply. That is the key mental model:

SCC (admission)SELinux (runtime)Effective confinement
restricted-v2container_t + MCS, enforcingStrong — both walls up
anyuid (root-in-container)container_t + MCS, enforcingUID 0 inside, but SELinux still blocks the host
privileged (→ spc_t)unconfinedNone — node-equivalent
restricted-v2SELinux permissiveHalf — a container escape has no runtime wall

Read row 2 carefully: running as root inside a container is not the same as root on the host, precisely because SELinux container_t still fences it. That is the value Layer 2 adds — and the value you throw away when a pod reaches spc_t.

Where Confinement Breaks

Over-broad SCC grantA shared/default SA bound to privileged or anyuid lets ordinary workloads request node-equivalent power.
Privileged → spc_tPrivileged pods run unconfined by SELinux, so a container escape reaches the real host.
hostPath + weak labelHost mounts under a host-access SCC expose node files the runtime label would otherwise have blocked.
Permissive nodeAny node in permissive mode removes Layer 2 cluster-wide for pods scheduled there.

On Vanilla Kubernetes (no SCC)

Upstream Kubernetes has no SCC. The equivalents:

  • Layer 1 → Pod Security Admission (the restricted profile ≈ restricted-v2) plus Kyverno/Gatekeeper for anything finer.
  • Layer 2 → the same kernel MAC, requested via securityContext.seLinuxOptions (or AppArmor/seccomp on non-SELinux distros). The difference is OpenShift assigns MCS categories for you; on vanilla you must design it.
kyverno: emulate restricted-v2 on vanilla k8s
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata: {name: restrict-like-scc}
spec:
  validationFailureAction: Enforce
  rules:
    - name: no-privileged-no-root
      match: {any: [{resources: {kinds: [Pod]}}]}
      validate:
        message: "No privileged, no root, drop ALL caps."
        pattern:
          spec:
            containers:
              - securityContext:
                  privileged: "false"
                  runAsNonRoot: true
                  allowPrivilegeEscalation: "false"
                  capabilities: {drop: ["ALL"]}

Advanced Techniques

Beyond "keep the defaults", the two layers become a genuine confinement tool when you author your own policy instead of reaching for the permissive built-ins.

Custom SCC instead of a blanket anyuid

When an image genuinely needs one deviation (say, a fixed UID or a single capability), don't grant anyuid/privileged — clone restricted-v2 and relax exactly one field. The workload gets what it needs; nothing else opens.

a minimal custom SCC — one deviation, nothing more
# start from restricted-v2, allow only NET_BIND_SERVICE, keep everything else locked
oc get scc restricted-v2 -o yaml > my-scc.yaml
# edit: metadata.name: bind-low-ports ; allowedCapabilities: ["NET_BIND_SERVICE"]
#       requiredDropCapabilities stays ["ALL"]; runAsUser stays MustRunAsRange
oc create -f my-scc.yaml
oc adm policy add-scc-to-user bind-low-ports -z my-sa   # scoped to one SA

Author real SELinux policy with udica

For a workload that must touch a specific host path or socket, generating a tailored SELinux type with udica beats dropping to spc_t (unconfined). The container gets exactly the label transitions it needs and stays confined for everything else.

udica: a bespoke type instead of unconfined spc_t
# inspect the running container's needs, generate a policy module
podman inspect <ctr> | udica my_workload
semodule -i my_workload.cil /usr/share/udica/templates/*.cil
# run under the tailored type — confined, but with just the access it needs
#   securityContext.seLinuxOptions.type: my_workload.process

Layer seccomp under both

SCC gates the request and SELinux fences filesystem access, but neither filters syscalls. Add seccompProfile: RuntimeDefault (the restricted-v2 default) — or a custom profile — so a container that is UID-confined and label-confined is also syscall-confined. Three orthogonal walls, not one.

detect the confinement holes at runtime
# pods running unconfined by SELinux (spc_t / privileged) — the ones to justify
oc get pods -A -o json | jq -r '.items[]
  | select((.spec.securityContext.seLinuxOptions.type // "") == "spc_t"
        or (.metadata.annotations["openshift.io/scc"] // "") == "privileged")
  | "\(.metadata.namespace)/\(.metadata.name)"'

# live SELinux denials = attack attempts or mislabels (never "fix" with permissive)
ausearch -m AVC -ts recent 2>/dev/null | audit2why 2>/dev/null | tail -20

Audit & Hardening Checklist

  • Inventory SCC grants. oc adm policy who-can use scc/privileged and .../anyuid — the list should be short, named, and justified.
  • Default to restricted-v2. Fix images (non-root, arbitrary-UID-friendly) instead of granting anyuid.
  • Keep every node Enforcing. Alert on getenforce != Enforcing; permissive is a cluster-wide hole.
  • Flag spc_t / privileged pods. They are unconfined at runtime — know each one and why it exists.
  • Watch SELinux denials. ausearch -m AVC / node audit logs surface both attacks and mislabels; don't "fix" a denial by going permissive.
find the unconfined and the escalatable
# pods admitted under a dangerous SCC
oc get pods -A -o json | jq -r '.items[]
  | select(.metadata.annotations["openshift.io/scc"]
      | . == "privileged" or . == "anyuid" or . == "hostaccess")
  | "\(.metadata.namespace)/\(.metadata.name)  scc=\(.metadata.annotations["openshift.io/scc"])"'

# SELinux denials on a node (attacks or mislabels)
ausearch -m AVC -ts recent 2>/dev/null | grep -i container_t | tail

SCC and SELinux are not competing controls or duplicate paperwork — they answer different questions at different moments, and OpenShift's confinement is the product of both. The failure mode is always the same: someone loosens one layer for convenience ("just add anyuid", "just set it permissive to clear the denials") and quietly halves the boundary. Keep the default SCC restricted, keep every node enforcing, treat privileged/spc_t as the node-equivalent it is, and read your AVC denials instead of silencing them. Two walls, both up.

— k8sec Security Research

See which pods are running unconfined — and who granted them.

K8SEC correlates SCC assignments, privileged/spc_t pods, host mounts and node SELinux state, so you can spot the workload that quietly dropped a wall before an attacker does.

Explore K8SEC

Sources: Red Hat OpenShift documentation on Security Context Constraints (built-in SCCs, assignment, restricted-v2) and container SELinux (container_t, MCS categories, spc_t); upstream Kubernetes Pod Security Admission and securityContext.seLinuxOptions; SELinux type-enforcement fundamentals. Commands are standard oc/kubectl; the risk matrix is k8sec's synthesis for defenders.

On this page

Contents

Two questions, two layersThe two-layer modelLayer 1 — SCC (admission)Layer 2 — SELinux (runtime)How they composeWhere confinement breaksOn vanilla KubernetesAdvanced techniquesAudit & hardeningFinal word