Vu Nguyen
← Essays & Opinions
5 min read

VMs, Containers, and the Code You Actually Trust

Containers isolate processes. VMs isolate kernels. The difference only matters when you run untrusted code — which is exactly when the difference is everything.

engineeringsecuritylinuxcontainersinfrastructure

Near the end of Copy Fail — the piece on CVE-2026-31431 — there is a note that didn't get much space:

This makes Copy Fail a container escape primitive too. The page cache is shared across the host kernel. A pod with the right primitives corrupts a setuid binary on the node and crosses tenant boundaries.

That sentence implies something most container deployments quietly ignore: the host kernel is shared, and that sharing is the attack surface.

What VMs Actually Provide

A virtual machine gives you a hardware-enforced boundary. The hypervisor runs at a higher privilege level than the guest kernel (VMX root on x86, EL2 on ARM). When guest code executes a syscall, the guest kernel handles it. When the guest kernel triggers a VM exit, the hypervisor intercepts. The host kernel is invisible to the guest.

Corrupt the guest kernel, and you own that guest. You do not own the host. The blast radius ends at the virtualization layer.

What Containers Actually Provide

A container is a set of Linux namespaces and cgroups. The PID namespace isolates the process view. The mount namespace controls filesystem visibility. Network namespaces isolate the network stack. Cgroups limit CPU and memory.

What all of this runs on: the host kernel. A process inside a container makes syscalls. Those syscalls hit the host kernel — the same kernel running every other container on that node.

The isolation is about visibility, not kernel execution. Container A cannot see Container B's processes. Container A and Container B both land in the same kernel codepath.

VM MODELAPPGUEST KERNELSECURITY BOUNDARYHYPERVISORHOST KERNELCONTAINER MODELCONTAINER ACONTAINER BHOST KERNELshared by all containersno kernel boundary
left: hardware boundary between guest and host — right: one shared kernel, one shared attack surface

The Kernel Attack Surface

The Linux kernel is roughly 30 million lines of code. Its syscall surface covers 300+ calls. Its subsystems include the crypto stack (AF_ALG), io_uring, netlink, eBPF, and the perf subsystem — each with its own CVE history, some exploitable from unprivileged userspace.

Copy Fail is the specific shape of this problem: no special privileges required, just the right sequence of syscalls into the crypto subsystem. Container process, container namespace, host kernel, host page cache, node compromise. The namespace tag in the process struct did not help.

Code Trust Levels

CONTAINERS OKYOUR SERVICESreviewed, tested, ownedPINNED OSS DEPSmaintained, auditedbehavior is characterizedVM REQUIREDVENDOR CODESOC 2, not a code auditAI-GENERATED CODEsyscall surface uncharacterizedUSER-SUBMITTED CODEci / plugins / agentskernel boundary required
the dividing line is syscall characterization — not language, not framework, not review count

The mental model most teams are missing is a trust level for the code they run. Not all workloads have the same risk profile.

Your production services have been reviewed, tested, and — ideally — have their dependencies pinned and audited. You know roughly what syscalls they make. These are fine in containers.

Open-source libraries you've pinned and kept current: acceptable in containers. Supply chain risk exists, but the behavior is bounded. Third-party vendor code running in your environment: you have a license agreement and a SOC 2, not a code audit. Slightly more concerning, but usually tolerable if you trust the vendor.

AI-generated code is the tricky one. AI can write code that makes unexpected syscalls, or has subtle logic errors that compose into privilege escalation paths the author didn't anticipate. Without syscall-level auditing — which most shops don't have capacity for — the behavior is uncharacterized. That's not a reason to avoid AI-generated code. It is a reason to think carefully about where it runs.

User-submitted code is the clearest case: CI runners, plugin execution, serverless functions from customers, agent sandboxes. Code you didn't write and can't fully validate. This needs a VM boundary.

The Practical Path

There are options that don't require giving up density entirely.

Firecracker is Amazon's MicroVM project, purpose-built for this problem. A Firecracker VM starts in under 150ms, uses about 5MB of memory overhead, and gives you a full hardware VM boundary. AWS Lambda and Fargate run on it. Startup latency is real but usually acceptable for workloads that need actual isolation.

gVisor is Google's user-space kernel. Container processes hit a Go user-space kernel (Sentry) rather than the host Linux kernel. Sentry then makes roughly 50 syscalls to the host instead of the full 300+. The host kernel attack surface shrinks significantly. gVisor has overhead, but it runs at Google production scale.

Kata Containers packages containers inside lightweight VMs, giving you the OCI interface on top of hardware isolation. Lower startup overhead than full KVM VMs, better isolation than standard containers.

The right choice depends on startup latency requirements, OCI compatibility needs, and density targets. All three are meaningfully safer for untrusted code than standard containers.

The Takeaway

Containers and VMs solve different problems. Containers are a deployment and dependency-isolation primitive. VMs are a security isolation primitive. Most stacks need both.

The question to ask is not "should we use containers or VMs" but "what is the trust level of the code we're running." Reviewed, owned services: containers are appropriate. Untrusted or unvalidated code: you need a kernel boundary.

The shared page cache, the AF_ALG socket, the io_uring subsystem — these sit inside the kernel boundary that containers do not cross. Copy Fail is a clean reminder of what "shared kernel" means in practice when the code doing the sharing hasn't been validated.


← Essays & Opinions