@@ -33,6 +33,10 @@ pub struct OciPolicy {
3333 /// Explicit bind mounts: (dest_inside_rootfs, host_source_path).
3434 pub fs_mount : Vec < ( PathBuf , PathBuf ) > ,
3535
36+ /// Destinations (subset of `fs_mount`) mounted read-only: the host procfs
37+ /// mount and `ro` bind mounts. Writes are denied even with a writable root.
38+ pub fs_mount_ro : Vec < PathBuf > ,
39+
3640 /// Initial working directory (relative to rootfs if set).
3741 pub cwd : Option < PathBuf > ,
3842
@@ -81,6 +85,7 @@ impl OciPolicy {
8185 let mut fs_read = Vec :: new ( ) ;
8286 let mut fs_write = Vec :: new ( ) ;
8387 let mut fs_mount = Vec :: new ( ) ;
88+ let mut fs_mount_ro = Vec :: new ( ) ;
8489 let mut scratch_dirs = Vec :: new ( ) ;
8590
8691 if rootfs. is_some ( ) {
@@ -100,7 +105,7 @@ impl OciPolicy {
100105 if let Some ( mounts) = spec. mounts ( ) {
101106 map_mounts (
102107 mounts, bundle, & rootfs, id,
103- & mut fs_mount, & mut fs_read, & mut fs_write, & mut scratch_dirs,
108+ & mut fs_mount, & mut fs_mount_ro , & mut fs_read, & mut fs_write, & mut scratch_dirs,
104109 ) ;
105110 }
106111
@@ -192,6 +197,7 @@ impl OciPolicy {
192197 fs_read,
193198 fs_write,
194199 fs_mount,
200+ fs_mount_ro,
195201 cwd,
196202 env,
197203 max_memory,
@@ -224,7 +230,11 @@ impl OciPolicy {
224230 builder = builder. fs_write ( path) ;
225231 }
226232 for ( virt, host) in & self . fs_mount {
227- builder = builder. fs_mount ( virt, host) ;
233+ if self . fs_mount_ro . iter ( ) . any ( |d| d == virt) {
234+ builder = builder. fs_mount_ro ( virt, host) ;
235+ } else {
236+ builder = builder. fs_mount ( virt, host) ;
237+ }
228238 }
229239
230240 if let Some ( ref cwd) = self . cwd {
@@ -330,16 +340,20 @@ fn rootfs_path(spec: &Spec, bundle: &Path) -> Result<Option<PathBuf>> {
330340/// - **tmpfs** writable scratch (`/tmp`, `/run`, `/dev/shm`, …): backed by a
331341/// host directory under the sandbox state dir and bind-mounted read-write,
332342/// so it works on a read-only root and stays isolated from the rootfs.
333- /// - **tmpfs at `/dev`, proc, sysfs**: passed through read-only so sandlock's
334- /// `/dev` interception and `/proc` virtualization service them; an empty
335- /// backing dir would shadow `/dev/null`, `/proc/*`, etc.
343+ /// - **proc**: `fs_mount(dest, /proc)` — the host procfs, so the sandbox gets a
344+ /// full `/proc` like a container; the seccomp `/proc` handler synthesizes the
345+ /// limit-aware files on top and blocks foreign PIDs / sensitive paths.
346+ /// - **tmpfs at `/dev`, sysfs**: passed through read-only so sandlock's `/dev`
347+ /// interception services them; an empty backing dir would shadow `/dev/null`,
348+ /// `/sys/*`, etc.
336349/// - **devpts/mqueue/cgroup**: skipped (no safe namespace-less equivalent).
337350fn map_mounts (
338351 mounts : & [ oci_spec:: runtime:: Mount ] ,
339352 bundle : & Path ,
340353 rootfs : & Option < PathBuf > ,
341354 id : & str ,
342355 fs_mount : & mut Vec < ( PathBuf , PathBuf ) > ,
356+ fs_mount_ro : & mut Vec < PathBuf > ,
343357 fs_read : & mut Vec < PathBuf > ,
344358 fs_write : & mut Vec < PathBuf > ,
345359 scratch_dirs : & mut Vec < PathBuf > ,
@@ -349,9 +363,22 @@ fn map_mounts(
349363 let mount_type = mount. typ ( ) . as_deref ( ) . unwrap_or ( "bind" ) ;
350364
351365 match mount_type {
352- // Kernel interfaces: pass the host's through read-only. sandlock
353- // virtualizes /proc and intercepts /dev/{null,urandom,random,…}.
354- "proc" => fs_read. push ( PathBuf :: from ( "/proc" ) ) ,
366+ // /proc: mount the host procfs READ-ONLY at the requested
367+ // destination so the sandbox gets a full /proc (version, stat,
368+ // self/*, <pid>/*, the directory listing, …) like a real container;
369+ // an empty rootfs /proc would otherwise leave it bare. The seccomp
370+ // /proc handler runs before this chroot mount, so it still (a)
371+ // synthesizes the cgroup/limit-aware files (meminfo, loadavg,
372+ // mounts, net/*) on top, and (b) blocks foreign PIDs and sensitive
373+ // paths. Read-only is essential: a writable host procfs would let
374+ // the sandbox write host-global controls like /proc/sys/* and
375+ // /proc/sysrq-trigger (a host-escape vector).
376+ "proc" => {
377+ fs_mount. push ( ( dest. to_path_buf ( ) , PathBuf :: from ( "/proc" ) ) ) ;
378+ fs_mount_ro. push ( dest. to_path_buf ( ) ) ;
379+ }
380+ // sysfs: pass the host's through read-only; sandlock's sensitive-path
381+ // filter still gates it.
355382 "sysfs" => fs_read. push ( PathBuf :: from ( "/sys" ) ) ,
356383
357384 "tmpfs" => {
@@ -399,6 +426,7 @@ fn map_mounts(
399426 fs_mount. push ( ( dest. to_path_buf ( ) , src) ) ;
400427 if read_only {
401428 fs_read. push ( dest. to_path_buf ( ) ) ;
429+ fs_mount_ro. push ( dest. to_path_buf ( ) ) ;
402430 } else {
403431 fs_write. push ( dest. to_path_buf ( ) ) ;
404432 }
@@ -644,8 +672,16 @@ mod tests {
644672 assert ! ( policy. scratch_dirs. iter( ) . any( |d| d. ends_with( "ctr1/tmpfs/tmp" ) ) ) ;
645673 assert ! ( policy. fs_write. contains( & PathBuf :: from( "/tmp" ) ) ) ;
646674
647- // /proc and /dev are passed through read-only, not emulated.
648- assert ! ( policy. fs_read. contains( & PathBuf :: from( "/proc" ) ) ) ;
675+ // /proc mounts the host procfs so the sandbox gets a full /proc; the
676+ // seccomp /proc handler synthesizes the limit-aware files on top.
677+ assert ! ( policy
678+ . fs_mount
679+ . iter( )
680+ . any( |( d, h) | d == Path :: new( "/proc" ) && h == Path :: new( "/proc" ) ) ) ;
681+ // ...and it is read-only, so the sandbox can't write host-global
682+ // controls like /proc/sys/* through the mount.
683+ assert ! ( policy. fs_mount_ro. contains( & PathBuf :: from( "/proc" ) ) ) ;
684+ // /dev is passed through read-only, not emulated.
649685 assert ! ( policy. fs_read. contains( & PathBuf :: from( "/dev" ) ) ) ;
650686 assert ! ( !policy. fs_mount. iter( ) . any( |( d, _) | d == Path :: new( "/dev" ) ) ) ;
651687 }
0 commit comments