@@ -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`.
181209pub struct NotificationListener {
182210 fd : OwnedFd ,
183211 wait_killable_recv : bool ,
184212}
185213
186214impl 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,13 +414,15 @@ pub fn install_listener(syscalls: &[i64]) -> io::Result<NotificationListener> {
330414 verify_notification_sizes ( ) ?;
331415 set_no_new_privileges ( ) ?;
332416
333- // WAIT_KILLABLE_RECV (Linux 5.19+) makes the supervisor's notification
334- // receive interruptible by a fatal signal. Kernels older than 5.19 (for
335- // example RHEL 9.x / 5.14 nodes) reject the flag with EINVAL. Rather than
336- // refusing to start there, fall back to a plain listener: the notification
337- // receive is then uninterruptible, but the sandbox is otherwise fully
338- // functional. The resulting listener records `wait_killable_recv = false`
339- // so callers can observe the degraded cancellation semantics.
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.
340426 match install_listener_with_flags ( syscalls, true ) {
341427 Ok ( listener) => Ok ( listener) ,
342428 Err ( error) if error. raw_os_error ( ) == Some ( libc:: EINVAL ) => {
@@ -939,4 +1025,52 @@ mod tests {
9391025 . expect_err ( "zero errno must fail" ) ;
9401026 assert_eq ! ( error. kind( ) , io:: ErrorKind :: InvalidInput ) ;
9411027 }
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+ }
9421076}
0 commit comments