Skip to main content

Container Orchestration·12 min read·

Sandboxing Untrusted Containers with gVisor: Lessons from G-Fuzz Vulnerability Research

Secure containers with gVisor sandboxing—prevent kernel exploits in Kubernetes clusters while managing 59% startup overhead for untrusted workloads.

Bottom Line Up Front

gVisor adds OS-level sandboxing to containers, preventing kernel exploits by intercepting syscalls in userspace. The G-Fuzz directed-fuzzing framework has found multiple serious vulnerabilities in gVisor, but it still outperforms runc for untrusted workloads. In my K3s cluster, gVisor increased container startup time from 42ms to 67ms (59% overhead) yet stopped the escape attempts I was able to test meaningfully.

Why it matters: CVE-2024-21626 (CVSS 8.6) enabled runc container escapes in January 2024. Docker patched it, but the vulnerability existed for years. gVisor’s userspace kernel prevents entire classes of these exploits.

The research: G-Fuzz (Li et al., IEEE TDSC vol. 21 no. 1, Jan-Feb 2024) is a directed fuzzing framework for gVisor out of Zhejiang University and Ant Group — not Google, who wrote gVisor itself. The authors report it significantly outperforms Syzkaller on gVisor and has been deployed in industry, where it detected multiple serious vulnerabilities.

play here, break nothing

The Container Escape Problem

Containers share the host kernel. One bad syscall can break containment. This is why container security hardening requires multiple layers of defense beyond just namespaces and cgroups.

Recent escapes:

  • CVE-2024-21626: runc working directory manipulation → host filesystem access
  • CVE-2024-23651: BuildKit race condition → host file exposure
  • CVE-2024-23652: BuildKit RUN --mount cleanup → arbitrary file deletion on the host (CVSS 9.1)
  • CVE-2024-23653: BuildKit GRPC API → privilege escalation

Snyk’s “Leaky Vessels” advisory details how attackers weaponized these. The common thread: kernel syscall filtering isn’t enough.

Standard container security relies on:

  • Namespaces: Isolate process trees, networks, filesystems
  • Cgroups: Limit CPU, memory, I/O
  • Seccomp-BPF: Block dangerous syscalls
  • AppArmor/SELinux: Mandatory access control

But: All these run in the kernel. Kernel bugs bypass them.

Container Process
NamespacesCgroupsSeccomp-BPFAppArmor / SELinux
Host Kernel
Hardware
Traditional containers add multiple guardrails, but every path still terminates at the shared host kernel.

All isolation mechanisms run inside the kernel. A single kernel vulnerability bypasses every layer.

Why it matters: You can harden seccomp profiles for weeks. One kernel 0-day undoes it all.

What gVisor Actually Does

gVisor inserts a userspace kernel between containers and the host.

Architecture:

Container → gVisor Sentry (userspace) → Host Kernel

The Sentry:

  • Written in Go, which eliminates most memory-corruption classes; the remaining unsafe code is quarantined into *_unsafe.go files by policy
  • Intercepts every syscall
  • Re-implements most of the Linux syscall surface in userspace
  • Only safe operations reach the host kernel

The Gofer:

  • Handles filesystem access via 9P protocol
  • Runs with minimal privileges
  • Is the component that does touch the host filesystem, so that the Sentry never has to
Container App
SentryGo userspace kernel; 200+ syscalls reimplementedGofer9P filesystem proxy; minimal privileges
Host Kernellimited syscall surfaceHost Filesystem
Container syscalls hit Sentry first; only safe host syscalls and scoped file requests continue into the host.

Key insight: Even if a container exploits a syscall bug, it’s exploiting Go code in userspace, not the kernel. No privilege escalation to host.

Trade-off: Performance. Every syscall crosses userspace boundary twice (container → Sentry → kernel → Sentry → container).

  1. Container App → Sentrysyscall, e.g. open()
  2. Sentry intercepts & validates the syscall
  3. If: file operation
  4. Sentry → Gofer9P file request
  5. Gofer → Host Kernelscoped host syscall
  6. Host Kernel → Goferfile data
  7. Gofer → Sentry9P response
  8. Else: non-file operation
  9. Sentry → Host Kernelfiltered host syscall
  10. Host Kernel → Sentryresult
  11. Then
  12. Sentry → Container Appsyscall result
  13. Dangerous syscalls never reach the host kernel

G-Fuzz: Finding Bugs in the Sandbox

G-Fuzz is a directed fuzzing framework targeting gVisor’s Go-based kernel.

The challenge: Traditional fuzzers like Syzkaller target C kernels. gVisor is Go. Different memory model, different vulnerabilities.

G-Fuzz innovations:

  1. Lightweight distance calculation: Measures how close inputs are to reaching target code paths without heavyweight instrumentation
  2. Syscall inference: Identifies which syscalls are most likely to trigger bugs in specific code regions
  3. Dynamic switching: Alternates between exploration (finding new code) and exploitation (triggering bugs)

Results:

  • Outperformed Syzkaller on gVisor by significant margins
  • Detected multiple serious vulnerabilities
  • Methods transferable to other OS kernels

What the paper doesn’t say: Exact CVE numbers or vulnerability details. Google likely embargoed specifics during responsible disclosure.

Why it matters: Even “secure by design” systems have bugs. Continuous fuzzing finds them before attackers do.

Deploying gVisor in My Homelab

I run a 3-node K3s cluster on Raspberry Pi 5s (16GB each) plus one Pi 4 (8GB). K3s is Kubernetes, stripped down.

Initial attempt: Deploy gVisor globally.

⚠️ Warning: These commands modify system configuration. Only use in controlled lab environments with proper backups.

# Install gVisor runtime
curl -fsSL https://gvisor.dev/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases release main" | sudo tee /etc/apt/sources.list.d/gvisor.list
sudo apt-get update && sudo apt-get install -y runsc

# Configure containerd
sudo runsc install
sudo systemctl restart containerd

Result: First pod wouldn’t start. Logs showed blocked syscalls.

Problem: gVisor implements 288 of 351 syscalls fully or partially, leaving 63 unsupported. Missing syscalls fail hard.

Solution: Use gVisor selectively via RuntimeClass.

# runtime.yaml
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc
---
# untrusted-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: untrusted-app
spec:
  runtimeClassName: gvisor
  containers:
  - name: app
    image: nginx:latest

This worked. Pods using runtimeClassName: gvisor run in gVisor. Everything else uses runc.

Debugging incompatible workloads:

# Check which syscalls a binary uses
strace -c nginx 2>&1 | grep -v "detached" | sort -n

# Compare against gVisor's supported syscalls
runsc debug --all | grep -o 'syscall.*' | sort

Took 2 hours tracing strace output to find that my custom monitoring sidecar used a ptrace option gVisor doesn’t implement (the syscall itself has partial support). Removed sidecar, monitoring works.

Current setup:

  • 12 of 30 pods run on gVisor (untrusted images, internet-facing services)
  • 18 pods run on runc (trusted workloads, performance-sensitive)
  • Zero compatibility issues after initial debugging

Performance Testing: gVisor vs runc

I benchmarked container startup, syscall overhead, and I/O performance.

Test environment:

  • Dell R910: 48 threads, 256GB RAM, Incus 6.0
  • 2 identical VMs: Ubuntu 24.04, 8 vCPUs, 16GB RAM
  • VM1: runc, VM2: gVisor (runsc)

Container startup (100 iterations):

# runc
time for i in {1..100}; do docker run --rm alpine:latest echo "test"; done
# Average: 42ms per container

# gVisor
time for i in {1..100}; do docker run --rm --runtime=runsc alpine:latest echo "test"; done
# Average: 67ms per container

Result: 59% overhead. Acceptable for untrusted workloads.

Syscall-heavy workload (compile Linux kernel):

# runc
time docker run --rm gcc:latest bash -c "apt-get update && apt-get install -y bc && wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.10.1.tar.xz && tar xf linux-5.10.1.tar.xz && cd linux-5.10.1 && make defconfig && make -j4"
# Time: 8m 32s

# gVisor
time docker run --rm --runtime=runsc gcc:latest bash -c "apt-get update && apt-get install -y bc && wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.10.1.tar.xz && tar xf linux-5.10.1.tar.xz && cd linux-5.10.1 && make defconfig && make -j4"
# Time: 11m 47s

Result: 38% overhead. Compile is syscall-heavy (file I/O, fork/exec).

Network throughput (nginx):

# runc
ab -n 100000 -c 100 http://runc-nginx/
# Requests/sec: 12,847

# gVisor
ab -n 100000 -c 100 http://gvisor-nginx/
# Requests/sec: 11,203

Result: 13% overhead. Network syscalls (socket, send, recv) cross userspace boundary.

Why it matters: gVisor cost me 13% on network throughput, 38% on a syscall-heavy compile, and 59% on container startup. For security-critical workloads, that’s acceptable. For performance-critical workloads, use runc.

Container Escape Testing

⚠️ Warning: This section demonstrates container escape techniques for educational purposes only. These tests should only be performed in isolated lab environments with proper authorization. Never attempt these techniques on production systems or systems you don’t own.

I attempted 5 common container escape techniques.

Test 1: Privileged container with host filesystem mount

# runc (baseline)
docker run --rm --privileged -v /:/host alpine chroot /host /bin/bash
# Result: Full host shell access ✓

# gVisor
docker run --rm --runtime=runsc --privileged -v /:/host alpine chroot /host /bin/bash
# Result: Permission denied ✗

Read this one carefully, because I originally drew the wrong conclusion from it. gVisor’s boundary is the host kernel, not the host filesystem. If you bind-mount / into the sandbox you have configured the gofer to serve the host root, and gVisor will serve it — the docs are explicit that it exposes exactly the paths the OCI config dictates. Don’t run this under any runtime. The --privileged flag disables seccomp/AppArmor but doesn’t give direct kernel access.

Test 2: /proc/sys/kernel write attempt

# Attempt to modify kernel parameters
docker run --rm --runtime=runsc alpine sh -c "echo 1 > /proc/sys/kernel/core_pattern"
# Result: Read-only file system ✗

Why gVisor blocked it: /proc/sys is a read-only overlay. No direct kernel parameter modification.

Test 3: cgroup release_agent exploit (CVE-2022-0492)

⚠️ Warning: This demonstrates a known container escape technique. Only use in isolated lab environments for educational purposes.

# Classic container escape technique
docker run --rm --runtime=runsc alpine sh -c "echo '/payload.sh' > /sys/fs/cgroup/memory/release_agent"
# Result: Operation not permitted ✗

Why gVisor blocked it: cgroups are emulated in Sentry. No direct host cgroup manipulation.

Test 4: Docker socket mount

# Mount Docker socket (common misconfiguration)
docker run --rm --runtime=runsc -v /var/run/docker.sock:/var/run/docker.sock docker:latest docker ps
# Result: Works, but limited to gVisor containers

Surprise: This works because Docker socket access isn’t a kernel exploit, it’s an API exploit. gVisor doesn’t protect against application-level attacks.

Mitigation: Don’t mount Docker sockets. Use least-privilege service accounts.

Test 5: Dirty Pipe (CVE-2022-0847) attempt

⚠️ Warning: This tests a known kernel vulnerability (CVE-2022-0847). Only use in isolated lab environments for educational purposes.

# Attempt to exploit pipe write vulnerability
# (Simplified test, actual exploit is more complex)
docker run --rm --runtime=runsc alpine sh -c "echo 'exploit' | tee /proc/self/mem"
# Result: Operation not permitted ✗

Why gVisor blocked it: Userspace kernel doesn’t have the vulnerable pipe implementation. Bug doesn’t exist in Sentry.

Summary: gVisor stopped 4 of 5 escapes. The 5th (Docker socket) isn’t a kernel exploit, so gVisor’s out of scope.

When to Use gVisor

Use gVisor for:

  • Untrusted container images (public registries, user-submitted code)
  • Multi-tenant workloads (SaaS platforms, CI/CD runners)
  • Internet-facing services (web apps, APIs) - combine with zero-trust architecture
  • Multi-tenant workloads where you need to argue isolation strength to an auditor

Don’t use gVisor for:

  • Performance-critical workloads (databases, real-time processing)
  • Syscall-heavy applications (compilers, development tools)
  • Unsupported syscalls (ptrace, some eBPF programs)
  • Trusted internal services (monitoring, logging)

My decision tree:

New Workload
Untrusted image?
Use gVisor
Internet-facing?
Use gVisor
Needs native performance?
Use runc
Syscall-heavy?
Use runc
Use runcprinciple of least surprise

Trade-off: Security vs performance. I choose security for attack surfaces, performance for internal services.

What I Learned

gVisor isn’t perfect.

  • G-Fuzz found bugs. More exist.
  • Syscall coverage gaps break some workloads.
  • Performance overhead ranged from 13% to 59% in my testing, depending entirely on syscall intensity.

But it’s better than alternatives:

  • Kata Containers: Heavier (full VMs), slower startup
  • Firecracker: AWS-specific, not Kubernetes-native
  • seccomp-only: Kernel bugs bypass it

The real lesson: Defense in depth. I use:

  • gVisor for untrusted containers
  • Network policies to limit lateral movement
  • Wazuh for syscall monitoring (integrate with threat intelligence)
  • Regular vulnerability scanning (Grype, Trivy)

Container security is layers. gVisor is one layer. A good one.

Practical Recommendations

Start small:

  1. Deploy gVisor on one node
  2. Test with non-critical workloads
  3. Profile performance for your use case
  4. Expand gradually

Monitor compatibility:

# Check for failed syscalls
kubectl logs <pod> | grep "syscall not supported"

# Enable debug logging
runsc --debug --debug-log=/tmp/runsc.log <container_id>

Tune for performance:

  • Use overlay filesystem — Google measured it halving gVisor’s sandboxing overhead on an abseil-cpp Bazel build
  • Enable seccomp optimization — note gVisor’s 2024 seccomp work cut filtering overhead ~29% on microbenchmarks but only ~1% of total runtime on real builds, and it ships on by default
  • Profile your workload with gVisor’s performance guide

Reality check: gVisor requires investment. Study syscall traces, understand your workload, measure performance. If you’re not willing to debug, stick with runc.

The Bigger Picture

G-Fuzz demonstrates that even secure-by-design systems need adversarial testing. gVisor’s Go implementation avoids memory corruption, but logic bugs remain.

Continuous fuzzing matters:

Defense ecosystem:

OPA / Kyverno
gVisor Sentry + Gofer
Falco / Tetragon
Cilium / Calico
Grype / Trivy
Attackers should be blocked at admission or runtime, detected by monitoring, and contained by network policy before scanning closes the loop.

No silver bullet. Security is understanding your threat model and layering controls.

Sources

  1. G-Fuzz: A Directed Fuzzing Framework for gVisor (2024)

    • J. Zhang et al.
    • IEEE Transactions on Dependable and Secure Computing
  2. Docker Security Advisory: Multiple Vulnerabilities in runc, BuildKit, and Moby (2024)

    • Docker Inc.
  3. Leaky Vessels: Docker and runc Container Breakout Vulnerabilities (2024)

    • Snyk Security Research Team
  4. CVE-2024-21626: runc process.cwd Container Breakout (2024)

    • National Vulnerability Database (NVD)
  5. What is gVisor? (2024)

    • Google Open Source
  6. gVisor Performance Guide (2024)

    • Google gVisor Documentation
  7. The True Cost of Containing: A gVisor Case Study (2019)

    • E. Young et al.
    • USENIX HotCloud
  8. Running gVisor in Production at Scale in Ant (2021)

    • Ant Group Engineering Team
  9. Optimizing seccomp Usage in gVisor (2024)

    • Google gVisor Team
  10. gVisor Improves Performance with Root Filesystem Overlay (2023)

    • Google Open Source Blog
  11. Kubernetes RuntimeClass Documentation (2024)

    • Kubernetes Documentation
  12. Container Security Best Practices (2024)

    • OWASP Foundation