Skip to content

Commit 293fab7

Browse files
authored
fix(sandbox): support kernels < 5.19 via seccomp WAIT_KILLABLE_RECV fallback (#3420)
* fix(sandbox): fall back to plain seccomp listener when WAIT_KILLABLE_RECV is unavailable SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV was added in Linux 5.19. On older kernels (for example RHEL 9.x / 5.14 nodes such as RHCOS on OpenShift) the flag is rejected with EINVAL, which made the capability-free sandbox fail to start during the notification probe with "notification launcher disappeared". Install the notification listener with WAIT_KILLABLE_RECV when the kernel supports it and fall back to a plain NEW_LISTENER on EINVAL. The fallback listener records wait_killable_recv = false: its notification receive is uninterruptible, but the sandbox is otherwise fully functional. Signed-off-by: Akram <akram.benaissi@gmail.com> * fix(sandbox): address review — legacy read-only listener mode + cancellation invariant Follow-up to the PR review (GATOR-de00bfcc-01 / mrunalp): make the < 5.19 fallback cancellation-safe instead of racing broker writes. - Record an explicit ListenerMode (Killable vs LegacyReadOnly); add writes_disabled()/mode() and emit the selected mode in qualification output (seccomp_listener_mode). - Centralize task-memory output writes behind NotificationListener:: write_task_output; in LegacyReadOnly mode getpeername, accept/accept4 with a non-null address, and sendmmsg length write-backs fail closed with EOPNOTSUPP. accept with a null address, socket/connect/bind/listen/sendto/sendmsg keep working (copied inputs, scalar responses, atomic ADDFD_SEND). - Enforce the launch invariant `cancellation || task_memory_writes_disabled` in SandboxConfirmEvidence::validate() rather than dropping cancellation unconditionally; add task_memory_writes_disabled to SeccompEvidence. - Add per-path fail-closed tests (write_task_output, write_socket_addr, a real plain listener installed on a modern kernel) and confirmation-invariant tests. - Correct the flag-semantics comments and document both modes plus the reduced legacy syscall compatibility in architecture/sandbox.md. Signed-off-by: Akram <akram.benaissi@gmail.com> * docs: document legacy read-only sandbox mode on kernels before 5.19 Kernels without SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV (< 5.19, e.g. RHEL 9.x / RHCOS 5.14) run the sandbox in a legacy read-only cancellation mode where the broker fails closed with EOPNOTSUPP on the mediated operations that write results back into workload memory (getpeername, accept/accept4 with a non-null address, sendmmsg length write-backs). Document this observable behavior and its syscall limitations in the public Fern docs: the support-matrix kernel requirements and the OpenShift runtime guidance. Signed-off-by: Akram <akram.benaissi@gmail.com> * style(sandbox): satisfy rustfmt and clippy doc_markdown Match the pinned rustfmt (Rust 1.95.0) line-wrapping for the write_task_output call, and backtick `legacy_read_only` in the qualification-report doc comment so clippy::doc_markdown (-D warnings) passes. Signed-off-by: Akram <akram.benaissi@gmail.com> * fix(sandbox): migrate task_memory_writes_disabled into backend protocol Add the `task_memory_writes_disabled` field to `SeccompEvidence` in the backend protocol contract and relax the validation from requiring `cancellation` to accepting `cancellation || task_memory_writes_disabled`. This completes the rebase migration missed by the isolation-interface refactor: the sandbox reports this field but the backend struct lacked it, and the validator rejected every pre-5.19 legacy listener. Signed-off-by: Akram <akram.benaissi@gmail.com> --------- Signed-off-by: Akram <akram.benaissi@gmail.com>
1 parent 5a81d2b commit 293fab7

9 files changed

Lines changed: 285 additions & 19 deletions

File tree

‎architecture/sandbox.md‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,9 +205,31 @@ The sandbox reserves `SIGUSR2` with a non-restarting no-op handler for these
205205
broker threads; startup rejects a conflicting handler. This signal disposition
206206
is process-global kernel state, while registrations and cancellation state are
207207
owned by the broker. Workload exec resets the caught handler to its default.
208-
This sandbox runtime requires Linux 6.2 or newer for Landlock ABI v3 and treats
209-
`SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV` as mandatory so cancelled
210-
notifications cannot race task-memory writes.
208+
This sandbox runtime requires Landlock ABI v3 (Linux 6.2, or an equivalent
209+
vendor backport). The seccomp listener is installed in one of two cancellation
210+
modes, and the launch confirmation enforces the invariant
211+
`cancellation || task_memory_writes_disabled`:
212+
213+
- **Killable** (`SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV`, Linux 5.19+): the
214+
notified workload thread waits kill-only, so a non-fatal signal cannot resume
215+
a mediated syscall between notification validation and the broker's result
216+
write. Full mediation, including task-memory output writes.
217+
- **LegacyReadOnly** (kernels < 5.19, e.g. RHEL 9.x / 5.14): the flag is
218+
unavailable (`EINVAL`), so the listener falls back to a plain notifier and the
219+
broker refuses every task-memory *output* write to stay cancellation-safe.
220+
Concretely, in this mode `getpeername`, `accept`/`accept4` **with a non-null
221+
peer-address argument**, and `sendmmsg` paths that write per-message lengths
222+
fail closed with `EOPNOTSUPP`. `accept` with a null address, and socket
223+
creation, `connect`, `bind`, `listen`, `sendto`, and `sendmsg` continue to
224+
work — they use copied inputs, scalar responses, or atomic `ADDFD_SEND`, none
225+
of which write into workload memory. Some server workloads whose accept
226+
wrappers request the peer address will therefore not run until the kernel
227+
provides `WAIT_KILLABLE_RECV` (a distribution backport); outbound-oriented
228+
workloads are unaffected.
229+
230+
Input mediation, DNS/TCP authorization, and outer-fence enforcement are
231+
identical in both modes. The selected mode is emitted in the sandbox
232+
qualification output (`seccomp_listener_mode`).
211233

212234
DNS uses an exact sandbox-local resolver at `127.0.0.53:53`. The driver sets the
213235
nameserver and permits an unprivileged bind to port 53. UDP and TCP DNS requests

‎crates/openshell-isolation-interface/src/linux/seccomp_notify.rs‎

Lines changed: 147 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -177,13 +177,52 @@ impl NotificationProbeReport {
177177
}
178178
}
179179

180+
/// Cancellation posture a listener was installed with.
181+
///
182+
/// `WAIT_KILLABLE_RECV` (Linux 5.19+) keeps the notified workload thread in a
183+
/// kill-only wait so a non-fatal signal cannot resume the mediated syscall
184+
/// after the broker has validated the notification. A plain listener has no
185+
/// such guarantee, so it runs read-only: the broker must refuse every
186+
/// task-memory *output* write to stay cancellation-safe.
187+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
188+
pub enum ListenerMode {
189+
/// Modern kernel: `WAIT_KILLABLE_RECV` active; full mediation including
190+
/// task-memory output writes.
191+
Killable,
192+
/// Legacy kernel (< 5.19): plain listener; task-memory output writes are
193+
/// disabled so a resumed syscall cannot race a broker write.
194+
LegacyReadOnly,
195+
}
196+
197+
impl ListenerMode {
198+
/// Stable identifier for qualification output and diagnostics.
199+
#[must_use]
200+
pub fn as_str(self) -> &'static str {
201+
match self {
202+
Self::Killable => "killable",
203+
Self::LegacyReadOnly => "legacy_read_only",
204+
}
205+
}
206+
}
207+
180208
/// Owned listener returned by `SECCOMP_FILTER_FLAG_NEW_LISTENER`.
181209
pub struct NotificationListener {
182210
fd: OwnedFd,
183211
wait_killable_recv: bool,
184212
}
185213

186214
impl NotificationListener {
215+
/// Construct a listener from an already-owned notification descriptor in a
216+
/// specific mode. Intended for tests that must exercise the legacy
217+
/// read-only fail-closed paths without a `< 5.19` kernel.
218+
#[must_use]
219+
pub fn from_fd_with_mode(fd: OwnedFd, mode: ListenerMode) -> Self {
220+
Self {
221+
fd,
222+
wait_killable_recv: matches!(mode, ListenerMode::Killable),
223+
}
224+
}
225+
187226
/// Raw listener descriptor for readiness integration and diagnostics.
188227
#[must_use]
189228
pub fn as_raw_fd(&self) -> RawFd {
@@ -196,6 +235,23 @@ impl NotificationListener {
196235
self.wait_killable_recv
197236
}
198237

238+
/// The cancellation mode this listener was installed with.
239+
#[must_use]
240+
pub fn mode(&self) -> ListenerMode {
241+
if self.wait_killable_recv {
242+
ListenerMode::Killable
243+
} else {
244+
ListenerMode::LegacyReadOnly
245+
}
246+
}
247+
248+
/// Whether broker task-memory output writes are disabled for this listener.
249+
/// True exactly in `LegacyReadOnly` mode (no `WAIT_KILLABLE_RECV`).
250+
#[must_use]
251+
pub fn writes_disabled(&self) -> bool {
252+
matches!(self.mode(), ListenerMode::LegacyReadOnly)
253+
}
254+
199255
/// Receive the next kernel notification.
200256
pub fn receive(&self) -> io::Result<Notification> {
201257
let mut raw = RawNotification::default();
@@ -223,6 +279,34 @@ impl NotificationListener {
223279
Ok(())
224280
}
225281

282+
/// Write broker-produced output into the notified task's memory, closing
283+
/// the validation-to-write race that a plain listener cannot.
284+
///
285+
/// In `Killable` mode `WAIT_KILLABLE_RECV` keeps the notified workload
286+
/// thread in a kill-only wait, so a non-fatal signal cannot resume the
287+
/// mediated syscall between `validate_id` and this write. In
288+
/// `LegacyReadOnly` mode (kernels < 5.19) there is no such guarantee: a
289+
/// resumed syscall could repurpose the target buffer while the privileged
290+
/// broker writes through the captured tid and pointer — via `/proc/<tid>/mem`
291+
/// even into pages the workload has since made read-only. There is no way to
292+
/// close that window without the flag, so this fails closed (`EOPNOTSUPP`)
293+
/// rather than racing. Callers must route every task-memory *output* write
294+
/// through this method; input reads never write workload memory and are
295+
/// unaffected.
296+
pub fn write_task_output(
297+
&self,
298+
id: u64,
299+
tid: u32,
300+
address: u64,
301+
data: &[u8],
302+
) -> io::Result<()> {
303+
if self.writes_disabled() {
304+
return Err(io::Error::from_raw_os_error(libc::EOPNOTSUPP));
305+
}
306+
self.validate_id(id)?;
307+
crate::linux::task_memory::write_exact(tid, address, data)
308+
}
309+
226310
/// Return a successful scalar result to the notifying syscall.
227311
pub fn respond_value(&self, id: u64, value: i64) -> io::Result<()> {
228312
self.validate_id(id)?;
@@ -330,16 +414,22 @@ pub fn install_listener(syscalls: &[i64]) -> io::Result<NotificationListener> {
330414
verify_notification_sizes()?;
331415
set_no_new_privileges()?;
332416

333-
install_listener_with_flags(syscalls, true).map_err(|error| {
334-
if error.raw_os_error() == Some(libc::EINVAL) {
335-
io::Error::new(
336-
io::ErrorKind::Unsupported,
337-
"seccomp WAIT_KILLABLE_RECV is required (Linux 5.19 or newer)",
338-
)
339-
} else {
340-
error
417+
// WAIT_KILLABLE_RECV (Linux 5.19+) keeps the *notified workload thread* in
418+
// a kill-only wait while the broker services its syscall, so a non-fatal
419+
// signal cannot resume the syscall and repurpose its buffers underneath a
420+
// pending broker write. Kernels older than 5.19 (for example RHEL 9.x /
421+
// 5.14 nodes) reject the flag with EINVAL. Rather than refusing to start
422+
// there, fall back to a plain listener so the sandbox boots; the resulting
423+
// listener records `wait_killable_recv = false`, and the broker then fails
424+
// closed on every task-memory output write (see `write_task_output`)
425+
// instead of racing them. Input mediation is unaffected.
426+
match install_listener_with_flags(syscalls, true) {
427+
Ok(listener) => Ok(listener),
428+
Err(error) if error.raw_os_error() == Some(libc::EINVAL) => {
429+
install_listener_with_flags(syscalls, false)
341430
}
342-
})
431+
Err(error) => Err(error),
432+
}
343433
}
344434

345435
/// Install the capability-free workload networking listener on the calling
@@ -935,4 +1025,52 @@ mod tests {
9351025
.expect_err("zero errno must fail");
9361026
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
9371027
}
1028+
1029+
#[test]
1030+
fn plain_listener_fails_closed_on_output_write() {
1031+
// A LegacyReadOnly listener (kernels < 5.19) must refuse every
1032+
// task-memory output write rather than race a resumed syscall. The
1033+
// guard short-circuits before touching the descriptor or workload
1034+
// memory, so a dup of stderr is a sufficient stand-in.
1035+
// SAFETY: dup takes one valid descriptor and returns a new descriptor
1036+
// or a negative error without modifying memory.
1037+
let duplicated = unsafe { libc::dup(libc::STDERR_FILENO) };
1038+
assert!(duplicated >= 0, "duplicate stderr for validation test");
1039+
// SAFETY: successful dup returned a new owned descriptor.
1040+
let listener = NotificationListener::from_fd_with_mode(
1041+
unsafe { OwnedFd::from_raw_fd(duplicated) },
1042+
ListenerMode::LegacyReadOnly,
1043+
);
1044+
assert!(listener.writes_disabled());
1045+
assert_eq!(listener.mode(), ListenerMode::LegacyReadOnly);
1046+
let error = listener
1047+
.write_task_output(1, 0, 0, &[0_u8; 4])
1048+
.expect_err("plain listener must reject output writes");
1049+
assert_eq!(error.raw_os_error(), Some(libc::EOPNOTSUPP));
1050+
}
1051+
1052+
#[test]
1053+
fn real_plain_listener_disables_output_writes() {
1054+
// Deliberately install a plain NEW_LISTENER (no WAIT_KILLABLE_RECV)
1055+
// even on a modern CI kernel and prove the broker write path fails
1056+
// closed on the real listener object.
1057+
set_no_new_privileges().expect("no_new_privs for listener install");
1058+
let listener = install_listener_with_flags(&[libc::SYS_getppid], false)
1059+
.expect("install plain listener");
1060+
assert_eq!(listener.mode(), ListenerMode::LegacyReadOnly);
1061+
assert!(listener.writes_disabled());
1062+
let error = listener
1063+
.write_task_output(1, 0, 0, &[0_u8; 4])
1064+
.expect_err("plain listener must reject output writes");
1065+
assert_eq!(error.raw_os_error(), Some(libc::EOPNOTSUPP));
1066+
}
1067+
1068+
#[test]
1069+
fn killable_listener_enables_output_writes() {
1070+
set_no_new_privileges().expect("no_new_privs for listener install");
1071+
let listener = install_listener_with_flags(&[libc::SYS_getppid], true)
1072+
.expect("install killable listener");
1073+
assert_eq!(listener.mode(), ListenerMode::Killable);
1074+
assert!(!listener.writes_disabled());
1075+
}
9381076
}

‎crates/openshell-sandbox-backend/src/boundary_protocol.rs‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ pub struct SeccompEvidence {
8282
pub task_memory_read: bool,
8383
pub task_memory_write: bool,
8484
pub cancellation: bool,
85+
pub task_memory_writes_disabled: bool,
8586
}
8687

8788
/// Mechanism-specific audit evidence for the native Linux sandbox adapter.
@@ -129,7 +130,7 @@ impl NativeLinuxSandboxAuditEvidence {
129130
&& self.seccomp.proc_fd_identity
130131
&& self.seccomp.task_memory_read
131132
&& self.seccomp.task_memory_write
132-
&& self.seccomp.cancellation
133+
&& (self.seccomp.cancellation || self.seccomp.task_memory_writes_disabled)
133134
&& self.landlock_abi >= 3
134135
&& self.landlock_allow_deny
135136
&& self.udp_dns_round_trip
@@ -1408,6 +1409,7 @@ mod tests {
14081409
task_memory_read: true,
14091410
task_memory_write: true,
14101411
cancellation: true,
1412+
task_memory_writes_disabled: false,
14111413
},
14121414
landlock_abi: 6,
14131415
landlock_allow_deny: true,
@@ -1438,6 +1440,22 @@ mod tests {
14381440
assert!(!audit.properties().egress_interception.enforced);
14391441
}
14401442

1443+
#[test]
1444+
fn audit_evidence_accepts_legacy_read_only_listener() {
1445+
let mut audit = complete_audit_evidence();
1446+
audit.seccomp.cancellation = false;
1447+
audit.seccomp.task_memory_writes_disabled = true;
1448+
assert!(audit.validate().is_ok());
1449+
}
1450+
1451+
#[test]
1452+
fn audit_evidence_rejects_plain_listener_with_writes_enabled() {
1453+
let mut audit = complete_audit_evidence();
1454+
audit.seccomp.cancellation = false;
1455+
audit.seccomp.task_memory_writes_disabled = false;
1456+
assert!(audit.validate().is_err());
1457+
}
1458+
14411459
#[test]
14421460
fn binary_identity_wire_rejects_ambiguous_or_invalid_shapes() {
14431461
for encoded in [

‎crates/openshell-sandbox-backend/src/runtime.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2623,6 +2623,7 @@ mod tests {
26232623
task_memory_read: true,
26242624
task_memory_write: true,
26252625
cancellation: true,
2626+
task_memory_writes_disabled: false,
26262627
},
26272628
landlock_abi: 3,
26282629
landlock_allow_deny: true,

‎crates/openshell-sandbox/src/boundary_server.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4329,6 +4329,7 @@ mod linux {
43294329
task_memory_read: true,
43304330
task_memory_write: true,
43314331
cancellation: true,
4332+
task_memory_writes_disabled: false,
43324333
},
43334334
landlock_abi: 6,
43344335
landlock_allow_deny: true,

‎crates/openshell-sandbox/src/main.rs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,11 @@ struct QualificationReport {
146146
tcp_allow_round_trip: bool,
147147
tcp_deny_round_trip: bool,
148148
wait_killable_recv: bool,
149+
/// Selected seccomp listener cancellation mode: `killable` (>= 5.19) or
150+
/// `legacy_read_only` (< 5.19, broker output writes disabled).
151+
seccomp_listener_mode: &'static str,
152+
/// Whether the broker disables task-memory output writes (legacy mode).
153+
task_memory_writes_disabled: bool,
149154
}
150155

151156
#[cfg(target_os = "linux")]
@@ -237,6 +242,12 @@ fn qualify_runtime() -> Result<(openshell_sandbox::RuntimeQualification, Qualifi
237242
tcp_allow_round_trip: true,
238243
tcp_deny_round_trip: true,
239244
wait_killable_recv: notification.wait_killable_recv,
245+
seccomp_listener_mode: if notification.wait_killable_recv {
246+
"killable"
247+
} else {
248+
"legacy_read_only"
249+
},
250+
task_memory_writes_disabled: !notification.wait_killable_recv,
240251
};
241252
let qualification = openshell_sandbox::RuntimeQualification {
242253
seccomp: openshell_sandbox_backend::boundary_protocol::SeccompEvidence {
@@ -249,6 +260,9 @@ fn qualify_runtime() -> Result<(openshell_sandbox::RuntimeQualification, Qualifi
249260
task_memory_read: notification.task_memory_copy(),
250261
task_memory_write: notification.task_memory_copy(),
251262
cancellation: notification.wait_killable_recv,
263+
// Legacy plain listener (< 5.19) disables broker output writes;
264+
// satisfies the `cancellation || writes_disabled` launch invariant.
265+
task_memory_writes_disabled: !notification.wait_killable_recv,
252266
},
253267
landlock_abi,
254268
landlock_allow_deny: true,

0 commit comments

Comments
 (0)