Skip to content

Commit 80dea15

Browse files
authored
Merge pull request #1 from spinupdev/codex/vfio-device-allowlist
feat: allow-list jailed VFIO devices
2 parents 34ca896 + 87490d3 commit 80dea15

8 files changed

Lines changed: 253 additions & 13 deletions

File tree

ARCHITECTURE.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ JSON manifest -> manifest validation -> Linux launch workflow
1717
its pure invariants.
1818
- `src/linux/mod.rs` owns operation order. It has no low-level mount, cgroup,
1919
or credential details.
20-
- `src/linux/jail.rs` stages the root, applies declared bind mounts, performs
21-
`pivot_root`, and creates only the KVM/TUN device nodes required by CH.
20+
- `src/linux/jail.rs` stages the root, applies declared bind mounts, resolves
21+
allow-listed VFIO character identities, performs `pivot_root`, and creates
22+
only the KVM/TUN/entropy and declared VFIO nodes required by CH.
2223
- `src/linux/cgroup.rs` owns cgroup-v2 discovery, controller delegation through
2324
`cgroup.subtree_control`, limit writes, and process attachment.
2425
- `src/linux/process.rs` owns namespaces, resource limits, descriptor and
@@ -38,8 +39,10 @@ Adopted or strengthened here:
3839
- strict versioned manifest rather than caller-provided arbitrary arguments;
3940
- bounded machine IDs, non-root target identity, path validation, and forced
4041
CH seccomp;
41-
- mount and PID namespaces, `pivot_root`, netns join, KVM/TUN nodes, rlimits,
42-
cgroup-v2 limits, and descriptor/environment sanitization;
42+
- mount and PID namespaces, `pivot_root`, netns join, KVM/TUN/entropy nodes,
43+
rlimits, cgroup-v2 limits, and descriptor/environment sanitization;
44+
- exact per-machine VFIO control/IOMMU-group allow-lists whose character
45+
identities are recreated inside the jail instead of exposing host `/dev`;
4346
- cgroup-v2 controller availability checks and recursive delegation before a
4447
leaf cgroup is configured; and
4548
- `close_range` with an `ENOSYS` fallback, rather than an unconditional
@@ -52,8 +55,8 @@ Intentional differences:
5255
- the API socket is created by CH inside the jail, not passed as a listener FD
5356
or bind mounted from the host;
5457
- Firecracker-specific userfaultfd support is not exposed; and
55-
- `/dev/urandom` is not created because CH uses host randomness through the
56-
kernel, not a jailed device path.
58+
- VFIO groups are supplied by the host allocator; this launcher validates the
59+
boundary but does not discover devices or decide assignment policy.
5760

5861
## Remaining hardening work
5962

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "cloud-hypervisor-jailer"
3-
version = "0.1.6"
3+
version = "0.1.10"
44
edition = "2024"
55
rust-version = "1.85"
66
description = "Cloud Hypervisor sandbox launcher for Depot"

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ On Linux, `launch` requires root and then:
2222
- mounts only declared non-symlink sources;
2323
- joins a pre-created network namespace when requested;
2424
- configures the declared cgroup-v2 values and resource limits;
25-
- creates jailed KVM and TUN device nodes;
25+
- creates jailed KVM, TUN, and entropy device nodes;
26+
- recreates only explicitly declared canonical VFIO control/group character
27+
devices, without bind-mounting host `/dev` or changing host device ownership;
2628
- creates a PID namespace when requested;
2729
- clears inherited environment and non-standard file descriptors;
2830
- drops the complete capability bounding set, sets `no_new_privs`, and changes
@@ -33,7 +35,7 @@ On Linux, `launch` requires root and then:
3335
flowchart LR
3436
O["Host orchestrator"] -->|"versioned JSON manifest"| V["validate"]
3537
V -->|"pure checks"| L["launch as root"]
36-
L --> J["jail\nmount namespace • bind mounts • pivot_root • KVM/TUN"]
38+
L --> J["jail\nmount namespace • bind mounts • pivot_root • KVM/TUN/VFIO"]
3739
L --> C["cgroup v2\ncontroller delegation • limits • lease"]
3840
L --> P["process\nnetns/PID ns • rlimits • FD/env cleanup • UID/GID"]
3941
J --> CH["Cloud Hypervisor\n--seccomp true"]
@@ -94,6 +96,14 @@ in [`src/manifest.rs`](src/manifest.rs) for the current canonical contract. Trea
9496
paths and arguments as host-orchestrator-controlled inputs; this is not a
9597
safe interface for tenant-provided configuration.
9698

99+
VFIO passthrough is opt-in per manifest. `devices` may contain only the exact
100+
control path `/dev/vfio/vfio` and canonical numeric IOMMU-group paths such as
101+
`/dev/vfio/42`; each destination must be the identical sandbox-relative path.
102+
Group entries require the control device. The jailer verifies every source is
103+
a real character device before pivoting and recreates its major/minor identity
104+
inside the private jail. Arbitrary host devices, symlinks, path aliases,
105+
destination remapping, duplicate nodes, and mounts over `dev/` are rejected.
106+
97107
## Status
98108

99109
The launcher is tested for manifest validation and compiled/tested on native

docs/testing.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ release used by the consuming orchestrator:
2626
for reconciler retry.
2727
5. Create/restore a CH snapshot through the host orchestrator; verify the
2828
jailer does not broaden storage or device visibility.
29+
6. On a dedicated VFIO host, pass one complete IOMMU group and verify the VMM
30+
sees only `/dev/vfio/vfio` plus that numeric group. Also verify arbitrary
31+
devices, a second unassigned group, symlinks, and mounts over `/dev` fail
32+
before Cloud Hypervisor execs.
2933

3034
The workflow is deliberately manual until a hardened dedicated runner exists.
3135
Never run it on a shared developer host or a runner that also contains tenant

src/linux/jail.rs

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
use std::env;
44
use std::ffi::CString;
55
use std::fs::{self, OpenOptions};
6+
use std::os::unix::fs::FileTypeExt;
7+
use std::os::unix::fs::MetadataExt;
68
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
79
use std::path::Path;
810

911
use anyhow::{Context, Result, bail};
1012

11-
use crate::manifest::{Manifest, Mount};
13+
use crate::manifest::{Device, Manifest, Mount};
1214

1315
use super::util::{c_path, syscall_ok};
1416

@@ -49,6 +51,50 @@ pub(super) fn mount_resources(manifest: &Manifest) -> Result<()> {
4951
Ok(())
5052
}
5153

54+
#[derive(Debug)]
55+
pub(super) struct ResolvedDevice {
56+
destination: std::path::PathBuf,
57+
major: u32,
58+
minor: u32,
59+
}
60+
61+
/// Resolve the device identity while the host `/dev` tree is still visible.
62+
/// Manifest validation has already constrained every path to canonical VFIO
63+
/// names; this second check proves each selected entry is a real character
64+
/// device rather than trusting a regular file at an allowed-looking path.
65+
pub(super) fn resolve_devices(manifest: &Manifest) -> Result<Vec<ResolvedDevice>> {
66+
manifest
67+
.devices
68+
.iter()
69+
.map(resolve_device)
70+
.collect::<Result<Vec<_>>>()
71+
}
72+
73+
fn resolve_device(device: &Device) -> Result<ResolvedDevice> {
74+
let metadata = fs::symlink_metadata(&device.source)
75+
.with_context(|| format!("stat device source {}", device.source.display()))?;
76+
if metadata.file_type().is_symlink() || !metadata.file_type().is_char_device() {
77+
bail!(
78+
"device source is not a character device: {}",
79+
device.source.display()
80+
);
81+
}
82+
let canonical = fs::canonicalize(&device.source)
83+
.with_context(|| format!("canonicalize device source {}", device.source.display()))?;
84+
if canonical != device.source {
85+
bail!(
86+
"device source changed during resolution: {}",
87+
device.source.display()
88+
);
89+
}
90+
let device_id = metadata.rdev();
91+
Ok(ResolvedDevice {
92+
destination: device.destination.0.clone(),
93+
major: libc::major(device_id) as u32,
94+
minor: libc::minor(device_id) as u32,
95+
})
96+
}
97+
5298
pub(super) fn pivot_into_jail(root: &Path) -> Result<()> {
5399
mount_call(Some(root), root, libc::MS_BIND | libc::MS_REC)?;
54100
env::set_current_dir(root).context("enter jail root")?;
@@ -66,14 +112,24 @@ pub(super) fn pivot_into_jail(root: &Path) -> Result<()> {
66112
syscall_ok(unsafe { libc::rmdir(old_root.as_ptr()) }).context("remove old root")
67113
}
68114

69-
pub(super) fn create_device_nodes(uid: u32, gid: u32) -> Result<()> {
115+
pub(super) fn create_device_nodes(uid: u32, gid: u32, devices: &[ResolvedDevice]) -> Result<()> {
70116
fs::create_dir_all("/dev/net").context("create jailed dev directory")?;
117+
if !devices.is_empty() {
118+
fs::create_dir_all("/dev/vfio").context("create jailed VFIO directory")?;
119+
}
71120
create_character_device(Path::new("/dev/kvm"), 10, 232)?;
72121
create_character_device(Path::new("/dev/net/tun"), 10, 200)?;
73122
// Cloud Hypervisor's default virtio-rng device reads from /dev/urandom.
74123
// Expose only this non-blocking entropy device; guest workloads never
75124
// receive the host /dev filesystem.
76125
create_character_device(Path::new("/dev/urandom"), 1, 9)?;
126+
for device in devices {
127+
let destination = Path::new("/").join(&device.destination);
128+
create_character_device(&destination, device.major, device.minor)
129+
.with_context(|| format!("create jailed device {}", destination.display()))?;
130+
chown_path(&destination, uid, gid)
131+
.with_context(|| format!("chown jailed device {}", destination.display()))?;
132+
}
77133
for path in [
78134
Path::new("/"),
79135
Path::new("/dev/kvm"),

src/linux/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ pub(crate) fn launch(manifest: &Manifest) -> Result<()> {
2020
.map(File::open)
2121
.transpose()
2222
.context("open network namespace")?;
23+
let devices = jail::resolve_devices(manifest)?;
2324

2425
jail::prepare_root(manifest)?;
2526
jail::enter_mount_namespace()?;
@@ -31,7 +32,7 @@ pub(crate) fn launch(manifest: &Manifest) -> Result<()> {
3132
// effective capabilities needed for pivot_root and device setup.
3233
process::drop_capability_bounding_set()?;
3334
jail::pivot_into_jail(&manifest.root)?;
34-
jail::create_device_nodes(manifest.uid, manifest.gid)?;
35+
jail::create_device_nodes(manifest.uid, manifest.gid, &devices)?;
3536
if let Some(netns) = netns {
3637
process::join_network_namespace(netns.as_raw_fd())?;
3738
}

0 commit comments

Comments
 (0)