Skip to content

Commit a343143

Browse files
author
Roy Lin
committed
fix(box): harden CRI from adversarial review (security + async-signal-safety + leaks)
Fixes confirmed by a multi-lens adversarial review of this session's CRI work: HIGH: - Security: a container image/pod env entry named A3S_SEC_* could spoof the runtime security envelope (the guest matches first-wins), escalating supplemental groups / unmasking paths / toggling seccomp. CreateContainer now strips all caller-supplied A3S_SEC_* before adding the runtime's trusted ones. - Async-signal-safety: the default seccomp BPF filter was built (heap alloc) INSIDE the post-fork pre_exec child — malloc can deadlock there on musl (multi-threaded guest). Split into build_default_bpf_filter (pre-fork) + install_seccomp_filter (syscalls only); both CRI-exec and box-run paths build the filter before fork and only install it in the child. - Mount leak: MaskedPaths/ReadonlyPaths were re-applied on every exec, stacking unbounded mounts on each liveness/readiness probe. apply_container_path_restrictions is now idempotent (mountpoint / ST_RDONLY checks). MEDIUM: - Graceful shutdown reaping was skipped when the gRPC server returned Err (`?`); it now always reaps, then propagates the error. - Non-Linux build fix: setgroups count cast (size_t vs BSD c_int) -> `as _`. - Path-traversal hardening: reject MaskedPaths/ReadonlyPaths and sysctl names containing `..` / `/` before joining onto the rootfs / /proc/sys path. LOW: - Localhost seccomp profiles now warn instead of silently downgrading. - Corrected the misleading default-seccomp doc comment. - A3S_SEC_* control vars are no longer leaked into the workload's environment. Verified on the KVM server: build + clippy clean, 219 cri tests pass, and seccomp-default / ReadonlyPaths / safe-sysctls / RemoveContainer-running all still pass (no regression from the refactor).
1 parent b0b9607 commit a343143

5 files changed

Lines changed: 134 additions & 43 deletions

File tree

src/cri/src/config_mapper.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,32 @@ fn parse_sysctls(config: &PodSandboxConfig) -> Vec<(String, String)> {
6161
let mut sysctls: Vec<(String, String)> = linux
6262
.sysctls
6363
.iter()
64+
.filter(|(name, _)| {
65+
let safe = is_safe_sysctl_name(name);
66+
if !safe {
67+
tracing::warn!(sysctl = %name, "Dropping sysctl with an unsafe name");
68+
}
69+
safe
70+
})
6471
.map(|(name, value)| (name.clone(), value.clone()))
6572
.collect();
6673
sysctls.sort();
6774
sysctls
6875
}
6976

77+
/// A sysctl name is safe to map onto `/proc/sys/<name with '.'→'/'>` only if it
78+
/// is a non-empty dot-separated key with no path-traversal characters. Guards
79+
/// against a crafted name (e.g. `../../proc/sysrq-trigger`) escaping
80+
/// `/proc/sys` when the guest substitutes `.` for `/`.
81+
fn is_safe_sysctl_name(name: &str) -> bool {
82+
!name.is_empty()
83+
&& !name.contains('/')
84+
&& !name.split('.').any(|seg| seg.is_empty() || seg == "..")
85+
&& name
86+
.chars()
87+
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
88+
}
89+
7090
fn parse_hostname(config: &PodSandboxConfig) -> Result<Option<String>> {
7191
let hostname = config.hostname.trim();
7292
if hostname.is_empty() {

src/cri/src/runtime_service/mod.rs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,12 @@ impl RuntimeService for BoxRuntimeService {
764764
.map(|image| image.env.as_slice())
765765
.unwrap_or(&[]);
766766
let mut env = merge_env(image_env, &config.envs);
767+
// A3S_SEC_* is the runtime's TRUSTED security envelope. Strip any
768+
// image- or pod-supplied A3S_SEC_* entry first: the guest matches these
769+
// keys first-wins, so a caller-controlled value would otherwise
770+
// override the runtime's (escalating supplemental groups, unmasking
771+
// paths, or toggling seccomp). Only runtime-derived values may set them.
772+
env.retain(|(key, _)| !key.starts_with("A3S_SEC_"));
767773
let working_dir = if config.working_dir.is_empty() {
768774
image_config
769775
.and_then(|image| image.working_dir.clone())
@@ -814,8 +820,20 @@ impl RuntimeService for BoxRuntimeService {
814820
// unconfined. Localhost profiles are not yet plumbed into the VM.
815821
if let Some(profile) = sc.seccomp.as_ref() {
816822
use crate::cri_api::security_profile::ProfileType;
817-
if profile.profile_type() == ProfileType::RuntimeDefault {
818-
env.push(("A3S_SEC_SECCOMP".to_string(), "default".to_string()));
823+
match profile.profile_type() {
824+
ProfileType::RuntimeDefault => {
825+
env.push(("A3S_SEC_SECCOMP".to_string(), "default".to_string()));
826+
}
827+
ProfileType::Localhost => {
828+
// Not yet plumbed into the VM — warn rather than
829+
// silently downgrading to unconfined.
830+
tracing::warn!(
831+
container = %metadata.name,
832+
profile = %profile.localhost_ref,
833+
"Localhost seccomp profiles are not yet supported; container runs without the requested profile"
834+
);
835+
}
836+
ProfileType::Unconfined => {}
819837
}
820838
}
821839
}

src/cri/src/server.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,16 +103,18 @@ impl CriServer {
103103
// Keep a handle so we can reap sandbox VMs once the server stops; the
104104
// service itself is moved into the gRPC server below.
105105
let shutdown_service = runtime_service.clone();
106-
Server::builder()
106+
let result = Server::builder()
107107
.add_service(RuntimeServiceServer::new(runtime_service))
108108
.add_service(ImageServiceServer::new(image_service))
109109
.serve_with_incoming_shutdown(uds_stream, shutdown_signal())
110-
.await?;
110+
.await;
111111

112-
// The server stopped (SIGTERM/SIGINT): tear down sandbox VMs and unmount
113-
// their overlays so they do not orphan across restarts.
112+
// The server stopped — on graceful signal (Ok) OR a transport error.
113+
// Reap sandbox VMs + unmount overlays unconditionally so they do not
114+
// orphan across restarts, then surface any server error.
114115
tracing::info!("CRI server stopping — reaping sandbox VMs");
115116
shutdown_service.shutdown_all_sandboxes().await;
117+
result?;
116118

117119
Ok(())
118120
}

src/guest/init/src/exec_server.rs

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,11 @@ fn build_command(
445445

446446
for entry in spec.env {
447447
if let Some((key, value)) = entry.split_once('=') {
448+
// A3S_SEC_* are runtime control vars (consumed below for setgroups /
449+
// masked paths / seccomp); don't leak them into the workload's env.
450+
if key.starts_with("A3S_SEC_") {
451+
continue;
452+
}
448453
command.env(key, value);
449454
}
450455
}
@@ -848,6 +853,14 @@ fn configure_child_process(
848853
.map(|rootfs| CString::new(rootfs.as_bytes()).expect("rootfs path was pre-validated"));
849854
let workdir = CString::new(workdir.as_bytes()).expect("working directory was pre-validated");
850855

856+
// Build the seccomp BPF filter BEFORE fork: building allocates, and
857+
// allocating in the post-fork child is not async-signal-safe (malloc may
858+
// deadlock on musl). The child only installs the prebuilt filter.
859+
#[cfg(target_os = "linux")]
860+
let seccomp_filter = apply_seccomp.then(crate::namespace::build_default_bpf_filter);
861+
#[cfg(not(target_os = "linux"))]
862+
let _ = apply_seccomp;
863+
851864
unsafe {
852865
command.pre_exec(move || {
853866
if libc::setpgid(0, 0) != 0 {
@@ -865,7 +878,7 @@ fn configure_child_process(
865878
// needs CAP_SETGID, which user.apply() drops via setuid below.
866879
if !supplemental_groups.is_empty() {
867880
let ret = libc::setgroups(
868-
supplemental_groups.len() as libc::size_t,
881+
supplemental_groups.len() as _,
869882
supplemental_groups.as_ptr() as *const libc::gid_t,
870883
);
871884
if ret != 0 {
@@ -876,14 +889,12 @@ fn configure_child_process(
876889
user.apply()?;
877890
}
878891
// Install the seccomp filter last — after chroot and the privilege
879-
// drop — so it is active across execve. The default filter allows
880-
// execve and the syscalls the child still needs.
892+
// drop — so it is active across execve. Only the prebuilt filter is
893+
// installed here; no allocation happens in the post-fork child.
881894
#[cfg(target_os = "linux")]
882-
if apply_seccomp {
883-
crate::namespace::apply_default_seccomp()?;
895+
if let Some(filter) = &seccomp_filter {
896+
crate::namespace::install_seccomp_filter(filter)?;
884897
}
885-
#[cfg(not(target_os = "linux"))]
886-
let _ = apply_seccomp;
887898
Ok(())
888899
});
889900
}
@@ -966,8 +977,36 @@ fn parse_sec_path_list<'a>(env: &'a [String], prefix: &str) -> Vec<&'a str> {
966977
fn apply_container_path_restrictions(rootfs: &str, masked: &[&str], readonly: &[&str]) {
967978
use nix::mount::{mount, MsFlags};
968979

980+
use std::os::unix::fs::MetadataExt;
981+
982+
// Reject entries that are not safe absolute paths (must be rooted, no `..`
983+
// component) so a crafted MaskedPaths/ReadonlyPaths value cannot escape the
984+
// container rootfs through the join below.
985+
let is_safe = |path: &str| path.starts_with('/') && !path.split('/').any(|c| c == "..");
986+
987+
// A target is already restricted if it is a distinct mount — its st_dev
988+
// differs from its parent directory's. This makes re-application on every
989+
// exec idempotent (build_command runs per-exec) instead of stacking mounts.
990+
let is_mountpoint = |target: &str| -> bool {
991+
let Ok(dev) = std::fs::metadata(target).map(|m| m.dev()) else {
992+
return false;
993+
};
994+
std::path::Path::new(target)
995+
.parent()
996+
.and_then(|p| std::fs::metadata(p).ok())
997+
.map(|pm| pm.dev() != dev)
998+
.unwrap_or(false)
999+
};
1000+
9691001
for path in masked {
1002+
if !is_safe(path) {
1003+
warn!("Skipping unsafe masked path {path}");
1004+
continue;
1005+
}
9701006
let target = format!("{rootfs}{path}");
1007+
if is_mountpoint(&target) {
1008+
continue; // already masked
1009+
}
9711010
match std::fs::metadata(&target) {
9721011
Ok(meta) if meta.is_dir() => {
9731012
if let Err(e) = mount(
@@ -996,10 +1035,22 @@ fn apply_container_path_restrictions(rootfs: &str, masked: &[&str], readonly: &[
9961035
}
9971036

9981037
for path in readonly {
1038+
if !is_safe(path) {
1039+
warn!("Skipping unsafe readonly path {path}");
1040+
continue;
1041+
}
9991042
let target = format!("{rootfs}{path}");
10001043
if !std::path::Path::new(&target).exists() {
10011044
continue;
10021045
}
1046+
// Idempotent: skip if already read-only — re-binding would stack mounts
1047+
// on every exec into the container.
1048+
if nix::sys::statvfs::statvfs(target.as_str())
1049+
.map(|s| s.flags().contains(nix::sys::statvfs::FsFlags::ST_RDONLY))
1050+
.unwrap_or(false)
1051+
{
1052+
continue;
1053+
}
10031054
// A fresh bind is read-write regardless of the source, so bind then
10041055
// remount read-only — the order matters.
10051056
if let Err(e) = mount(

src/guest/init/src/namespace.rs

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -324,9 +324,19 @@ fn apply_security_before_exec(cmd: &mut Command) -> Result<(), NamespaceError> {
324324
let seccomp_mode = config.seccomp.clone();
325325
let cap_drop = config.cap_drop.clone();
326326

327+
// Build the seccomp BPF filter BEFORE fork. Building allocates, which is
328+
// not async-signal-safe in the post-fork child (malloc may deadlock on
329+
// musl); the child only installs the prebuilt filter.
330+
let seccomp_filter = if matches!(seccomp_mode, SeccompMode::Default) {
331+
Some(build_default_bpf_filter())
332+
} else {
333+
None
334+
};
335+
327336
// Use pre_exec to apply security in the child process right before exec
328337
// SAFETY: pre_exec runs after fork, before exec. We only call
329-
// async-signal-safe operations (prctl, seccomp).
338+
// async-signal-safe operations (prctl, seccomp) — the seccomp filter is
339+
// built above, pre-fork.
330340
unsafe {
331341
cmd.pre_exec(move || {
332342
// 1. Set no-new-privileges
@@ -342,10 +352,12 @@ fn apply_security_before_exec(cmd: &mut Command) -> Result<(), NamespaceError> {
342352
drop_capabilities(&cap_drop)?;
343353
}
344354

345-
// 3. Apply seccomp filter
355+
// 3. Apply seccomp filter (prebuilt before fork)
346356
match &seccomp_mode {
347357
SeccompMode::Default => {
348-
apply_default_seccomp()?;
358+
if let Some(filter) = &seccomp_filter {
359+
install_seccomp_filter(filter)?;
360+
}
349361
}
350362
SeccompMode::Unconfined => {
351363
// No seccomp filter
@@ -496,38 +508,26 @@ fn cap_name_to_number(name: &str) -> Option<i32> {
496508
///
497509
/// Based on Docker's default seccomp profile — blocks syscalls that could
498510
/// escape the sandbox or compromise the host.
511+
/// Install a prebuilt seccomp BPF filter on the current thread.
512+
///
513+
/// **Async-signal-safe**: performs only `prctl` and the `seccomp` syscall and
514+
/// reads the caller-owned `filter` slice — it does NOT allocate. It is therefore
515+
/// safe to call from a post-fork `pre_exec` hook PROVIDED the filter was built
516+
/// *before* the fork (see [`build_default_bpf_filter`]); building the filter
517+
/// allocates and must never run in the child of a multi-threaded process.
518+
///
519+
/// Sets `PR_SET_NO_NEW_PRIVS` (required for unprivileged seccomp) then loads the
520+
/// filter via `SECCOMP_SET_MODE_FILTER`, putting the process in
521+
/// `SECCOMP_MODE_FILTER` (`/proc/self/status` `Seccomp: 2`). The default filter
522+
/// returns `EPERM` for the syscalls listed in [`build_default_bpf_filter`].
499523
#[cfg(target_os = "linux")]
500-
pub(crate) fn apply_default_seccomp() -> Result<(), std::io::Error> {
501-
// Use SECCOMP_SET_MODE_FILTER via prctl
502-
// The default profile uses a BPF filter that blocks:
503-
// - kexec_load, kexec_file_load (kernel replacement)
504-
// - reboot (system reboot)
505-
// - mount, umount2 (filesystem manipulation — unless in mount namespace)
506-
// - pivot_root, chroot (filesystem escape)
507-
// - swapon, swapoff (swap manipulation)
508-
// - init_module, finit_module, delete_module (kernel modules)
509-
// - acct (process accounting)
510-
// - settimeofday, clock_settime (time manipulation)
511-
// - personality (execution domain change)
512-
// - keyctl (kernel keyring)
513-
// - ptrace (process tracing — unless CAP_SYS_PTRACE)
514-
// - userfaultfd (memory manipulation)
515-
// - perf_event_open (performance monitoring)
516-
// - bpf (eBPF programs)
517-
// - unshare (namespace creation — already in namespace)
518-
// - setns (namespace switching)
519-
520-
// Build BPF filter program
521-
let filter = build_default_bpf_filter();
522-
523-
// Install the filter via prctl + seccomp
524-
// First, ensure no-new-privs is set (required for unprivileged seccomp)
524+
pub(crate) fn install_seccomp_filter(filter: &[libc::sock_filter]) -> Result<(), std::io::Error> {
525+
// First, ensure no-new-privs is set (required for unprivileged seccomp).
525526
let ret = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) };
526527
if ret != 0 {
527528
return Err(std::io::Error::last_os_error());
528529
}
529530

530-
// SECCOMP_SET_MODE_FILTER = 1, SECCOMP_FILTER_FLAG_TSYNC = 1
531531
let prog = libc::sock_fprog {
532532
len: filter.len() as u16,
533533
filter: filter.as_ptr() as *mut libc::sock_filter,
@@ -547,7 +547,7 @@ pub(crate) fn apply_default_seccomp() -> Result<(), std::io::Error> {
547547
/// Returns SECCOMP_RET_ERRNO(EPERM) for blocked syscalls,
548548
/// SECCOMP_RET_ALLOW for everything else.
549549
#[cfg(target_os = "linux")]
550-
fn build_default_bpf_filter() -> Vec<libc::sock_filter> {
550+
pub(crate) fn build_default_bpf_filter() -> Vec<libc::sock_filter> {
551551
// BPF constants
552552
const BPF_LD: u16 = 0x00;
553553
const BPF_W: u16 = 0x00;

0 commit comments

Comments
 (0)