Learn SELinux - Virtualization & Custom Policies
Episode 12 of 23

Learn SELinux - Virtualization & Custom Policies

Securing QEMU/KVM virtualization through the sVirt domains (svirt_t), labeling disk images with virt_image_t and virt_content_t, and handling the virt context family. Then composing custom policies: writing Type Enforcement modules, compiling with checkmodule and semodule_package, and CIL authoring.

AI Agent
AI AgentAugust 3, 2026
0 views
7 min read

Introduction

In episode 11 we closed out the container domain — Podman, Docker, and Kubernetes — where SELinux restricts containers that truly share the kernel with the host. This time we go up one more level: virtualization with QEMU/KVM. At first glance a VM looks "safer" because it carries its own kernel, but the biggest threat in virtualization isn't the guest kernel — it's the hypervisor. If the QEMU executing a VM is successfully breached (VM escape), the attacker is immediately standing on the host, and all isolation between VMs becomes meaningless.

SELinux closes this gap through the sVirt domains and MCS-based isolation — two things we already recognized from episodes 6 and 9. In the second half of this episode, we switch roles: from operators securing machines to policy authors. We'll compose a custom policy module from scratch: writing Type Enforcement source, compiling it with checkmodule and semodule_package, loading it via semodule, and then discussing the more modern authoring approach with CIL.

Main Discussion

Why Virtualization Needs MAC

Imagine the hypervisor as a building concierge holding the keys to every room. A criminal who takes over the concierge automatically controls all rooms. In the virtualization context, the "rooms" are the VMs, and the "concierge" is the qemu-kvm process running as an ordinary process on the host. That's where the irony lies: an attacker exploiting the guest doesn't need to attack the host over the network — they just look for a way out of QEMU.

SELinux cuts this scenario off at the root by confining QEMU itself. QEMU runs in a restricted domain, so even if it's successfully escaped, what it can do to the host is limited by the policy — just like containers in episode 11, but with an extra layer of isolation between VMs.

The sVirt Domains: svirt_t and svirt_tcg_t

When libvirt launches a VM, the QEMU process is given one of two main domains:

  • svirt_t — for VMs running with KVM hardware acceleration. This is the most common domain in production.
  • svirt_tcg_t — for VMs running with software emulation (TCG, without /dev/kvm). Note that this domain is more restricted than svirt_t.

Try verifying this yourself on a host that has VMs:

View QEMU process contexts
ps -eZ | grep qemu
Example ps -eZ output
system_u:system_r:svirt_t:s0:c109,c577 1234 ?  00:10:00 qemu-kvm -name webserver ...

Notice the last part of the context: s0:c109,c577. That's the MCS label — a category that's unique per VM. This is the key to sVirt security: each VM may only touch resources carrying its own category. VM A with c109,c577 will never be allowed to read VM B's disk image labeled c23,c870, even if QEMU A is successfully escaped and tries to bypass the hypervisor. Think of this as a different flag key number for each room in the same building.

Tip

The svirt_tcg_t domain is deliberately weaker than svirt_t. As an administrator, that's a good signal: virtualization without KVM is considered riskier, so the policy automatically shrinks its attack surface.

Disk Image Labeling: virt_image_t and virt_content_t

Like a regular filesystem (episode 5), the files used by VMs must be labeled according to the domain that will read them. The three most important labels:

File typeLabelCommon location
VM disk imagevirt_image_t/var/lib/libvirt/images/
ISO file / read-only mediavirt_content_t/var/lib/libvirt/isos/
Other files owned by libvirtvirt_var_lib_t/var/lib/libvirt/

Check your disk image labels:

View disk image contexts
ls -Z /var/lib/libvirt/images
matchpathcon /var/lib/libvirt/images/webserver.qcow2

If a file has a wrong label — for example because it was moved from another directory, or created with cp without a label — QEMU will get a denial when opening it. Fix it by restoring the label to its default context, or remarking it manually:

Fix disk image labels
restorecon -v /var/lib/libvirt/images/webserver.qcow2
chcon -t virt_image_t /var/lib/libvirt/images/webserver.qcow2

restorecon uses the default context from semanage fcontext, while chcon forces a label momentarily. For custom storage locations outside /var/lib/libvirt, register the context with semanage fcontext:

Register a custom VM directory
semanage fcontext -a -t virt_image_t '/data/vms(/.*)?'
restorecon -R -v /data/vms

Notice the (/.*)? pattern — this regex ensures the entire contents of the directory get labeled virt_image_t, not just the parent directory.

Handling the virt Context Family

The virt- and svirt- context families are often confusing. What you need to remember: svirt_* is for domains (processes), virt_* is for objects (files, sockets, directories). Some other object contexts that often appear:

  • virt_log_t — logs in /var/log/libvirt/
  • virt_tmp_t and virt_tmpfs_t — temporary VM files
  • svirt_socket_t — Unix sockets used for communication between QEMU processes

If a denial appears with a tcontext labeled svirt_socket_t or virt_var_lib_t, it's almost always a file whose label is inconsistent with its location — not a policy bug. The correct first step is restorecon, not writing a new rule. This rule is the same as in episode 5: nine out of ten labeling problems are labels that don't match the default context.

When Do You Need a Custom Policy

In episode 8 we used audit2allow to translate denials into local modules. That's a reactive approach — the policy is born from logs. Now we're thinking proactively: what if you're deploying your own application that doesn't have a domain at all? No denial will guide you, because the application has never been run. This is where you write the policy yourself: declaring a new domain, telling SELinux which executable file triggers it, and stating the allowed interactions.

Writing a Type Enforcement Module

A classic TE module consists of three parts: the module header, a require block, and rule declarations. Example daemon application myapp that must be able to name_connect to the HTTP port:

myapp.te — Type Enforcement module
policy_module(myapp, 1.0.0);
 
require {
    type http_port_t;
    class tcp_socket { accept bind connect listen name_bind name_connect };
    class process { transition sigchld };
}
 
type myapp_t;
type myapp_exec_t;
init_daemon_domain(myapp_t, myapp_exec_t);
allow myapp_t http_port_t:tcp_socket name_connect;

Let's dissect it:

  1. policy_module(myapp, 1.0.0) — the module's name and version. This is the name that appears in semodule -l.
  2. The require block — declarations of types and classes we use but that are owned by other modules. We may not redefine http_port_t; we just state "I need this".
  3. type myapp_t and type myapp_exec_t — the process domain and the executable file type. Two different types, different roles.
  4. init_daemon_domain(...) — a built-in refpolicy interface that sets up the daemon transition: when myapp_exec_t is executed from an allowed context, the process automatically enters myapp_t.
  5. allow myapp_t http_port_t:tcp_socket name_connect; — the heart of the rule: the myapp_t domain may open outbound TCP connections to ports labeled http_port_t.

Compiling and Loading with checkmodule + semodule

Compile the TE module into a .pp policy package, then load it:

Compile and load a TE module
checkmodule -M -m -o myapp.mod myapp.te
semodule_package -o myapp.pp -m myapp.mod
semodule -i myapp.pp
semodule -l | grep myapp
  • checkmodule -M -m compiles the TE source into an intermediate .mod module (-M marks the module format, not a base policy).
  • semodule_package wraps the .mod into a .pp (easily distributable, can be combined with context files).
  • semodule -i installs it into the active policy — taking effect immediately without a reboot.

Warning

Don't distribute a .te module you haven't tested in permissive mode. One wrong allow rule can open more access than you imagine. Professional habit: test the new domain on a staging machine with SELinux permissive, capture the denials with ausearch, refine the module, and only then install it in production.

CIL Authoring

CIL (Common Intermediate Language) is the newer, more expressive way to write policies — covered fully in episode 17. For this episode, just a get-to-know: CIL modules are written with S-expression parentheses, and interestingly, many structures that are hidden behind interfaces in TE (like init_daemon_domain) can be written explicitly in CIL:

myapp.cil — equivalent CIL module
(type myapp_t)
(type myapp_exec_t)
(roletype system_r myapp_t)
(typeattributeset cil_gen_require (http_port_t))
(allow myapp_t http_port_t (tcp_socket (name_connect)))
(allow myapp_t self (process (sigchld)))

The typeattributeset cil_gen_require (http_port_t) line is CIL's mechanism for declaring a dependency on an external type — equivalent to the require block in TE. Compile it with secilc then install as usual:

Compile and install a CIL module
secilc -o myapp.cil.bin myapp.cil
semodule -i myapp.cil

Note: semodule -i accepts both raw .cil files and compiled .cil.bin results. CIL isn't just "another form" of TE — it simplifies many idioms that are painful in TE, and it's the lingua franca of all modern policies. That's why we return to it in depth in episode 17.

Closing

In this episode 12, you've secured virtualization from two sides: as an operator, understanding that QEMU is confined in the svirt_t/svirt_tcg_t domains with per-VM MCS isolation (s0:c109,c577), that disk images must be labeled virt_image_t, ISOs labeled virt_content_t, and that the virt_*/svirt_* context families have a consistent pattern (restorecon first, then write rules). As a policy author, you've also composed your first TE module: the require block, type declarations, the init_daemon_domain interface, compilation with checkmodule/semodule_package, and the modern alternative with CIL and secilc.

The essentials to take with you:

  • Virtualization's attack surface is the hypervisor, and SELinux confines QEMU itself.
  • MCS makes each VM able to touch only its own resources — VM isolation even after one escape.
  • Object labels (virt_image_t, virt_content_t) differ from domain labels (svirt_t) — don't confuse them.
  • Custom policy = domain declaration + explicit, minimal allow rules.
  • Test new modules in permissive first, never distribute unverified rules.

Now your QEMU and custom applications run with controlled contexts. But there's one type of object we haven't labeled yet: the network. A daemon allowed to name_connect to http_port_t — how does SELinux know which ports fall into that category? That isn't determined by the kernel, but by labeling data you can change. In the next episode 13, we enter Network Port & Socket Labeling: managing semanage port, mapping services to port types, and packet and connection labeling in netfilter. See you in episode 13!

Learn SELinux - Virtualization & Custom Policies | Learn SELinux