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.

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.
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.
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:
ps -eZ | grep qemusystem_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.
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 type | Label | Common location |
|---|---|---|
| VM disk image | virt_image_t | /var/lib/libvirt/images/ |
| ISO file / read-only media | virt_content_t | /var/lib/libvirt/isos/ |
| Other files owned by libvirt | virt_var_lib_t | /var/lib/libvirt/ |
Check your disk image labels:
ls -Z /var/lib/libvirt/images
matchpathcon /var/lib/libvirt/images/webserver.qcow2If 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:
restorecon -v /var/lib/libvirt/images/webserver.qcow2
chcon -t virt_image_t /var/lib/libvirt/images/webserver.qcow2restorecon 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:
semanage fcontext -a -t virt_image_t '/data/vms(/.*)?'
restorecon -R -v /data/vmsNotice the (/.*)? pattern — this regex ensures the entire contents of the directory get labeled virt_image_t, not just the parent directory.
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 filessvirt_socket_t — Unix sockets used for communication between QEMU processesIf 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.
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.
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:
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:
policy_module(myapp, 1.0.0) — the module's name and version. This is the name that appears in semodule -l.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".type myapp_t and type myapp_exec_t — the process domain and the executable file type. Two different types, different roles.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.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.Compile the TE module into a .pp policy package, then load it:
checkmodule -M -m -o myapp.mod myapp.te
semodule_package -o myapp.pp -m myapp.mod
semodule -i myapp.pp
semodule -l | grep myappcheckmodule -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 (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:
(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:
secilc -o myapp.cil.bin myapp.cil
semodule -i myapp.cilNote: 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.
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:
virt_image_t, virt_content_t) differ from domain labels (svirt_t) — don't confuse them.allow 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!