|
3 | 3 |
|
4 | 4 | //! `OpenShell` capability-free in-workload sandbox boundary. |
5 | 5 |
|
| 6 | +// Crate-scoped: Cargo rejects mixing `[lints] workspace = true` with a |
| 7 | +// crate-specific lint table (see #5188). |
| 8 | +#![warn(clippy::undocumented_unsafe_blocks)] |
| 9 | +#![cfg_attr(test, allow(clippy::undocumented_unsafe_blocks))] |
| 10 | + |
6 | 11 | #[cfg(target_os = "linux")] |
7 | 12 | use std::mem::size_of; |
8 | 13 | use std::path::Path; |
@@ -685,6 +690,10 @@ fn probe_socket_virtualization() -> Result<()> { |
685 | 690 | { |
686 | 691 | return Err(std::io::Error::last_os_error()).into_diagnostic(); |
687 | 692 | } |
| 693 | + // SAFETY: the retained source is the exact injected OFD and |
| 694 | + // the payload buffer stays live for its declared length; |
| 695 | + // MSG_NOSIGNAL keeps a refused send from raising SIGPIPE in |
| 696 | + // the dispatcher thread. |
688 | 697 | let sent = unsafe { |
689 | 698 | libc::send( |
690 | 699 | retained.as_raw_fd(), |
@@ -796,6 +805,15 @@ fn set_child_core_limit() -> std::io::Result<()> { |
796 | 805 | .map_err(std::io::Error::from) |
797 | 806 | } |
798 | 807 |
|
| 808 | +/// Reset every catchable signal disposition in the calling process to its |
| 809 | +/// default. |
| 810 | +/// |
| 811 | +/// # Safety |
| 812 | +/// |
| 813 | +/// Destroys all installed handlers for signals 1 through 64 (skipping the |
| 814 | +/// uncatchable SIGKILL and SIGSTOP) in the calling process. Must only run |
| 815 | +/// in the forked pre-exec child, never in the launcher parent, which still |
| 816 | +/// needs its own dispositions. |
799 | 817 | #[cfg(target_os = "linux")] |
800 | 818 | #[allow(unsafe_code)] |
801 | 819 | unsafe fn reset_child_signal_dispositions() -> std::io::Result<()> { |
@@ -1217,43 +1235,44 @@ fn probe_child_self_protection(sandbox_tgid: libc::pid_t, socket: libc::c_int) - |
1217 | 1235 | ) |
1218 | 1236 | }; |
1219 | 1237 | require_probe_errno(read, libc::EPERM, "process_vm_readv sandbox")?; |
1220 | | - // SAFETY: signal zero would only probe process existence if the filter did |
1221 | | - // not reject the trusted sandbox target. |
1222 | 1238 | require_probe_errno( |
| 1239 | + // SAFETY: signal zero would only probe process existence if the |
| 1240 | + // filter did not reject the trusted sandbox target. |
1223 | 1241 | isize::try_from(unsafe { libc::kill(sandbox_tgid, 0) }).expect("kill result fits isize"), |
1224 | 1242 | libc::EPERM, |
1225 | 1243 | "kill sandbox", |
1226 | 1244 | )?; |
1227 | 1245 | // SAFETY: scalar syscall arguments request a read-only resource query; |
1228 | 1246 | // the child filter rejects non-self targets. |
| 1247 | + let prlimit_result = isize::try_from(unsafe { |
| 1248 | + libc::syscall(libc::SYS_prlimit64, sandbox_tgid, libc::RLIMIT_CORE, 0, 0) |
| 1249 | + }) |
| 1250 | + .expect("prlimit result fits isize"); |
| 1251 | + require_probe_errno(prlimit_result, libc::EPERM, "prlimit sandbox")?; |
| 1252 | + // SAFETY: signal zero on the process group would only probe group |
| 1253 | + // existence if the filter did not reject the trusted sandbox target. |
| 1254 | + let group_kill_result = unsafe { libc::kill(-sandbox_tgid, 0) }; |
1229 | 1255 | require_probe_errno( |
1230 | | - isize::try_from(unsafe { |
1231 | | - libc::syscall(libc::SYS_prlimit64, sandbox_tgid, libc::RLIMIT_CORE, 0, 0) |
1232 | | - }) |
1233 | | - .expect("prlimit result fits isize"), |
1234 | | - libc::EPERM, |
1235 | | - "prlimit sandbox", |
1236 | | - )?; |
1237 | | - require_probe_errno( |
1238 | | - isize::try_from(unsafe { libc::kill(-sandbox_tgid, 0) }).expect("kill result fits isize"), |
| 1256 | + isize::try_from(group_kill_result).expect("kill result fits isize"), |
1239 | 1257 | libc::EPERM, |
1240 | 1258 | "process-group signal", |
1241 | 1259 | )?; |
| 1260 | + // SAFETY: F_SETOWN would only record the sandbox target as the owner of |
| 1261 | + // the child's own socket if the filter did not reject it first. |
| 1262 | + let fcntl_result = unsafe { libc::fcntl(socket, libc::F_SETOWN, sandbox_tgid) }; |
1242 | 1263 | require_probe_errno( |
1243 | | - isize::try_from(unsafe { libc::fcntl(socket, libc::F_SETOWN, sandbox_tgid) }) |
1244 | | - .expect("fcntl result fits isize"), |
| 1264 | + isize::try_from(fcntl_result).expect("fcntl result fits isize"), |
1245 | 1265 | libc::EPERM, |
1246 | 1266 | "fcntl F_SETOWN", |
1247 | 1267 | )?; |
1248 | 1268 | let mut owner = sandbox_tgid; |
1249 | | - require_probe_errno( |
1250 | | - isize::try_from(unsafe { |
1251 | | - libc::syscall(libc::SYS_ioctl, socket, 0x8901_u32, &raw mut owner) |
1252 | | - }) |
1253 | | - .expect("ioctl result fits isize"), |
1254 | | - libc::EPERM, |
1255 | | - "ioctl FIOSETOWN", |
1256 | | - )?; |
| 1269 | + // SAFETY: FIOSETOWN would only record the sandbox target as the owner of |
| 1270 | + // the child's own socket if the filter did not reject it first. |
| 1271 | + let ioctl_result = isize::try_from(unsafe { |
| 1272 | + libc::syscall(libc::SYS_ioctl, socket, 0x8901_u32, &raw mut owner) |
| 1273 | + }) |
| 1274 | + .expect("ioctl result fits isize"); |
| 1275 | + require_probe_errno(ioctl_result, libc::EPERM, "ioctl FIOSETOWN")?; |
1257 | 1276 | Ok(()) |
1258 | 1277 | } |
1259 | 1278 |
|
@@ -2039,4 +2058,149 @@ mod tests { |
2039 | 2058 | let final_path = dest_dir.join("openshell-sandbox"); |
2040 | 2059 | assert!(final_path.exists(), "binary should land inside dest dir"); |
2041 | 2060 | } |
| 2061 | + |
| 2062 | + /// Environment variable marking the re-executed probe child process. |
| 2063 | + const PROBE_CHILD_ENV: &str = "OPENSHELL_SANDBOX_PROBE_CHILD"; |
| 2064 | + |
| 2065 | + /// Re-executes `test_path` (the full libtest path, module prefix |
| 2066 | + /// included) in a fresh process so it can change its rlimits and load |
| 2067 | + /// seccomp filters without contaminating the test harness. The child |
| 2068 | + /// asserts on its own probe results; this parent only requires a |
| 2069 | + /// successful exit. A fresh executable process, unlike a raw `fork` child |
| 2070 | + /// of the multithreaded test harness, may allocate freely after the |
| 2071 | + /// filter is installed. |
| 2072 | + fn run_isolated_probe_child(test_path: &str) { |
| 2073 | + let output = std::process::Command::new(std::env::current_exe().expect("test executable")) |
| 2074 | + .args(["--exact", test_path, "--nocapture"]) |
| 2075 | + .env(PROBE_CHILD_ENV, "1") |
| 2076 | + .output() |
| 2077 | + .expect("start isolated probe child process"); |
| 2078 | + let stdout = String::from_utf8_lossy(&output.stdout); |
| 2079 | + assert!( |
| 2080 | + output.status.success(), |
| 2081 | + "isolated probe child failed:\nstdout: {stdout}\nstderr: {}", |
| 2082 | + String::from_utf8_lossy(&output.stderr) |
| 2083 | + ); |
| 2084 | + // `--exact` matches full test paths; a wrong name would silently |
| 2085 | + // select zero tests and exit successfully. |
| 2086 | + assert!( |
| 2087 | + stdout.contains("1 passed"), |
| 2088 | + "isolated probe child matched no test:\nstdout: {stdout}" |
| 2089 | + ); |
| 2090 | + } |
| 2091 | + |
| 2092 | + /// Prepares a probe child: zeroes the core-dump limit the probe requires |
| 2093 | + /// and allows the unprivileged seccomp install. |
| 2094 | + #[allow(unsafe_code)] |
| 2095 | + fn prepare_probe_child() -> std::io::Result<()> { |
| 2096 | + nix::sys::resource::setrlimit(nix::sys::resource::Resource::RLIMIT_CORE, 0, 0) |
| 2097 | + .map_err(std::io::Error::from)?; |
| 2098 | + // SAFETY: PR_SET_NO_NEW_PRIVS is a one-way scalar transition required |
| 2099 | + // before loading an unprivileged seccomp filter. |
| 2100 | + if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } < 0 { |
| 2101 | + return Err(std::io::Error::last_os_error()); |
| 2102 | + } |
| 2103 | + Ok(()) |
| 2104 | + } |
| 2105 | + |
| 2106 | + /// The production child filter rejects every self-protection probe with |
| 2107 | + /// EPERM. Running `probe_child_self_protection` against that exact filter |
| 2108 | + /// keeps each hoisted probe result wired to its own errno check. The test |
| 2109 | + /// re-executes itself in a fresh process so the rlimit and filter changes |
| 2110 | + /// never touch the test harness process. |
| 2111 | + #[test] |
| 2112 | + fn probe_child_self_protection_accepts_the_production_child_filter() { |
| 2113 | + if std::env::var_os(PROBE_CHILD_ENV).is_none() { |
| 2114 | + run_isolated_probe_child( |
| 2115 | + "tests::probe_child_self_protection_accepts_the_production_child_filter", |
| 2116 | + ); |
| 2117 | + return; |
| 2118 | + } |
| 2119 | + prepare_probe_child().expect("prepare probe child"); |
| 2120 | + let tgid = std::process::id(); |
| 2121 | + let sandbox_tgid = libc::pid_t::try_from(tgid).expect("test pid fits pid_t"); |
| 2122 | + let mut program = openshell_sandbox::linux::child_seccomp::prepare(tgid) |
| 2123 | + .expect("prepare production child filter"); |
| 2124 | + program.install().expect("install production child filter"); |
| 2125 | + probe_child_self_protection(sandbox_tgid, -1) |
| 2126 | + .expect("probes pass under the production child filter"); |
| 2127 | + } |
| 2128 | + |
| 2129 | + /// Denies every probe except the FIOSETOWN ioctl with EPERM; the kernel |
| 2130 | + /// then rejects the ioctl's invalid descriptor with EBADF. The |
| 2131 | + /// distinct errno only satisfies the check that belongs to the ioctl probe |
| 2132 | + /// when every check runs directly after its own syscall. |
| 2133 | + fn probe_adjacency_filter() -> seccompiler::BpfProgram { |
| 2134 | + use seccompiler::{ |
| 2135 | + SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter, |
| 2136 | + SeccompRule, |
| 2137 | + }; |
| 2138 | + use std::collections::BTreeMap; |
| 2139 | + |
| 2140 | + let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new(); |
| 2141 | + rules.insert(libc::SYS_process_vm_readv, Vec::new()); |
| 2142 | + rules.insert(libc::SYS_kill, Vec::new()); |
| 2143 | + rules.insert( |
| 2144 | + libc::SYS_prlimit64, |
| 2145 | + vec![ |
| 2146 | + SeccompRule::new(vec![ |
| 2147 | + SeccompCondition::new(0, SeccompCmpArgLen::Dword, SeccompCmpOp::Ne, 0) |
| 2148 | + .expect("valid prlimit64 argument condition"), |
| 2149 | + ]) |
| 2150 | + .expect("valid prlimit64 rule"), |
| 2151 | + ], |
| 2152 | + ); |
| 2153 | + rules.insert( |
| 2154 | + libc::SYS_fcntl, |
| 2155 | + vec![ |
| 2156 | + SeccompRule::new(vec![ |
| 2157 | + SeccompCondition::new( |
| 2158 | + 1, |
| 2159 | + SeccompCmpArgLen::Dword, |
| 2160 | + SeccompCmpOp::Eq, |
| 2161 | + u64::from(libc::F_SETOWN.cast_unsigned()), |
| 2162 | + ) |
| 2163 | + .expect("valid fcntl command condition"), |
| 2164 | + ]) |
| 2165 | + .expect("valid fcntl rule"), |
| 2166 | + ], |
| 2167 | + ); |
| 2168 | + let architecture = std::env::consts::ARCH |
| 2169 | + .try_into() |
| 2170 | + .expect("supported target architecture"); |
| 2171 | + SeccompFilter::new( |
| 2172 | + rules, |
| 2173 | + SeccompAction::Allow, |
| 2174 | + SeccompAction::Errno(libc::EPERM.cast_unsigned()), |
| 2175 | + architecture, |
| 2176 | + ) |
| 2177 | + .expect("build probe filter") |
| 2178 | + .try_into() |
| 2179 | + .expect("compile probe filter") |
| 2180 | + } |
| 2181 | + |
| 2182 | + #[test] |
| 2183 | + fn probe_child_self_protection_checks_each_syscall_errno_independently() { |
| 2184 | + if std::env::var_os(PROBE_CHILD_ENV).is_none() { |
| 2185 | + run_isolated_probe_child( |
| 2186 | + "tests::probe_child_self_protection_checks_each_syscall_errno_independently", |
| 2187 | + ); |
| 2188 | + return; |
| 2189 | + } |
| 2190 | + prepare_probe_child().expect("prepare probe child"); |
| 2191 | + let filter = probe_adjacency_filter(); |
| 2192 | + seccompiler::apply_filter(&filter).expect("install adjacency filter"); |
| 2193 | + let sandbox_tgid = libc::pid_t::try_from(std::process::id()).expect("test pid fits pid_t"); |
| 2194 | + // The invalid descriptor is enough: the fcntl probe is rejected by |
| 2195 | + // the filter before the kernel validates it, and the unfiltered |
| 2196 | + // ioctl probe reports EBADF from the kernel. |
| 2197 | + let error = probe_child_self_protection(sandbox_tgid, -1) |
| 2198 | + .expect_err("adjacency filter must let the ioctl probe through"); |
| 2199 | + assert!( |
| 2200 | + error |
| 2201 | + .to_string() |
| 2202 | + .contains("ioctl FIOSETOWN was not rejected with errno"), |
| 2203 | + "unexpected probe failure: {error}" |
| 2204 | + ); |
| 2205 | + } |
2042 | 2206 | } |
0 commit comments