Skip to content

Commit d855d06

Browse files
committed
fix(sandbox): reject local control peers when the kernel denies the ingress filter
Binding the boundary control listener attaches a classic BPF filter that drops loopback-interface ingress. The fix for CVE-2026-53236 gates SO_ATTACH_FILTER on CAP_NET_ADMIN, and a sandbox pod holds no capabilities at all, so the attach returns EPERM and every sandbox dies at startup with: bind boundary control listener: Operation not permitted (os error 1) Granting the capability is not an option: the native Linux audit evidence requires an empty capability set, so a sandbox that could attach the filter would fail its own qualification instead. Treat that EPERM as "filter unavailable" and move the check to accept time. Any peer the workload can reach the listener from arrives over `lo`, so one end is loopback or the two ends share an address; a supervisor runs in a different pod and presents a different address. Every non-loopback listener applies the check regardless of whether the filter attached, so a sandbox behaves the same on any kernel and the filter is an extra standing layer where it is available. The peer address comes from accept() rather than getpeername(), which fails with ENOTCONN when a peer resets while still queued; the serve loop also treats NotConnected, ConnectionReset and ConnectionAborted as per-connection errors so a reset cannot stop the listener. An unavailable filter is reported as an OCSF ConfigStateChange so operators can see which sandboxes run in this mode. The isolation-interface filter tests now skip when the kernel denies the attach, since they fail on every patched kernel without CAP_NET_ADMIN. Signed-off-by: divesh <[email protected]>
1 parent 834b79a commit d855d06

2 files changed

Lines changed: 172 additions & 23 deletions

File tree

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

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ pub fn bound_device(fd: impl AsFd) -> io::Result<Option<Vec<u8>>> {
3838
SockRef::from(&fd).device()
3939
}
4040

41-
/// Drop TCP/UDP ingress that arrives on the loopback interface.
41+
/// Try to drop TCP/UDP ingress that arrives on the loopback interface,
42+
/// reporting whether the kernel allowed it.
4243
///
4344
/// Attach this to a trusted listener whose legitimate clients are never in the
4445
/// same network namespace. Matching the ingress interface rather than the
@@ -48,13 +49,21 @@ pub fn bound_device(fd: impl AsFd) -> io::Result<Option<Vec<u8>>> {
4849
/// which marks every descriptor above stdio close-on-exec before running
4950
/// workload code.
5051
///
52+
/// The fix for CVE-2026-53236 gates `SO_ATTACH_FILTER` on `CAP_NET_ADMIN`, so
53+
/// on a patched kernel a capability-free caller gets `Ok(false)` and must
54+
/// reject same-namespace peers some other way.
55+
///
5156
/// # Errors
5257
///
53-
/// Returns the kernel error when the interface index cannot be resolved or
54-
/// the filter cannot be attached.
55-
pub fn reject_loopback_ingress(fd: impl AsFd) -> io::Result<()> {
58+
/// Returns the kernel error for any failure other than the gated attach;
59+
/// `EPERM` from the interface lookup stays an error.
60+
pub fn try_reject_loopback_ingress(fd: impl AsFd) -> io::Result<bool> {
5661
let index = rustix::net::netdevice::name_to_index(&fd, "lo")?;
57-
reject_ingress_interface(fd, index)
62+
match reject_ingress_interface(fd, index) {
63+
Ok(()) => Ok(true),
64+
Err(error) if error.raw_os_error() == Some(libc::EPERM) => Ok(false),
65+
Err(error) => Err(error),
66+
}
5867
}
5968

6069
fn reject_ingress_interface(fd: impl AsFd, index: u32) -> io::Result<()> {
@@ -219,7 +228,10 @@ mod tests {
219228
#[test]
220229
fn loopback_ingress_filter_rejects_loopback_connections() {
221230
let listener = TcpListener::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap();
222-
reject_loopback_ingress(&listener).unwrap();
231+
if !try_reject_loopback_ingress(&listener).unwrap() {
232+
eprintln!("skipping: this kernel gates SO_ATTACH_FILTER on CAP_NET_ADMIN");
233+
return;
234+
}
223235
listener.set_nonblocking(true).unwrap();
224236
let port = listener.local_addr().unwrap().port();
225237
// Dropped SYNs never complete the handshake.
@@ -235,7 +247,14 @@ mod tests {
235247
// Positive control: the same program keyed to an absent interface
236248
// index must leave loopback traffic untouched.
237249
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
238-
reject_ingress_interface(&listener, u32::MAX).unwrap();
250+
match reject_ingress_interface(&listener, u32::MAX) {
251+
Ok(()) => {}
252+
Err(error) if error.raw_os_error() == Some(libc::EPERM) => {
253+
eprintln!("skipping: this kernel gates SO_ATTACH_FILTER on CAP_NET_ADMIN");
254+
return;
255+
}
256+
Err(error) => panic!("attach ingress filter: {error}"),
257+
}
239258
let mut client = connect_with_timeout(listener.local_addr().unwrap()).unwrap();
240259
let (mut accepted, _) = listener.accept().unwrap();
241260
client.write_all(b"ping").unwrap();

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

Lines changed: 146 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -578,7 +578,15 @@ mod linux {
578578
});
579579
}
580580
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
581-
Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {}
581+
// A refused or already-dead peer must not stop the listener.
582+
Err(error)
583+
if matches!(
584+
error.kind(),
585+
io::ErrorKind::PermissionDenied
586+
| io::ErrorKind::NotConnected
587+
| io::ErrorKind::ConnectionReset
588+
| io::ErrorKind::ConnectionAborted
589+
) => {}
582590
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
583591
std::thread::sleep(Duration::from_millis(10));
584592
}
@@ -3277,6 +3285,7 @@ mod linux {
32773285
Tcp {
32783286
listener: std::net::TcpListener,
32793287
server_config: Arc<rustls::ServerConfig>,
3288+
reject_local_peers: bool,
32803289
},
32813290
}
32823291

@@ -3305,12 +3314,13 @@ mod linux {
33053314
})
33063315
}
33073316
BoundaryListenerConfig::TlsTcp { address, tls } => {
3308-
let listener = Self::bind_tcp(*address)?;
3317+
let (listener, reject_local_peers) = Self::bind_tcp(*address)?;
33093318
listener.set_nonblocking(true)?;
33103319
let server_config = Arc::new(load_tls_server_config(tls)?);
33113320
Ok(Self::Tcp {
33123321
listener,
33133322
server_config,
3323+
reject_local_peers,
33143324
})
33153325
}
33163326
}
@@ -3320,22 +3330,30 @@ mod linux {
33203330
/// before it listens so workload sockets cannot reach it through
33213331
/// loopback or the pod's own address. Configuration rejects loopback
33223332
/// addresses; tests bind them without the filter.
3323-
fn bind_tcp(address: std::net::SocketAddr) -> io::Result<std::net::TcpListener> {
3333+
///
3334+
/// The filter is a standing guarantee where the kernel allows it, but
3335+
/// `accept` repeats the check for every non-loopback listener so a
3336+
/// sandbox behaves the same whatever kernel its node runs. Returns
3337+
/// whether `accept` must apply it.
3338+
fn bind_tcp(address: std::net::SocketAddr) -> io::Result<(std::net::TcpListener, bool)> {
33243339
let socket = socket2::Socket::new(
33253340
socket2::Domain::for_address(address),
33263341
socket2::Type::STREAM,
33273342
Some(socket2::Protocol::TCP),
33283343
)?;
33293344
socket.set_cloexec(true)?;
33303345
socket.set_reuse_address(true)?;
3331-
if !address.ip().is_loopback() {
3332-
openshell_isolation_interface::linux::socket_confinement::reject_loopback_ingress(
3346+
let reject_local_peers = !address.ip().is_loopback();
3347+
if reject_local_peers
3348+
&& !openshell_isolation_interface::linux::socket_confinement::try_reject_loopback_ingress(
33333349
&socket,
3334-
)?;
3350+
)?
3351+
{
3352+
report_unavailable_ingress_filter();
33353353
}
33363354
socket.bind(&address.into())?;
33373355
socket.listen(128)?;
3338-
Ok(socket.into())
3356+
Ok((socket.into(), reject_local_peers))
33393357
}
33403358

33413359
fn bind_vsock(port: u32) -> io::Result<OwnedFd> {
@@ -3428,8 +3446,12 @@ mod linux {
34283446
Self::Tcp {
34293447
listener,
34303448
server_config,
3449+
reject_local_peers,
34313450
} => {
3432-
let (stream, _) = listener.accept()?;
3451+
let (stream, peer) = listener.accept()?;
3452+
if *reject_local_peers {
3453+
reject_workload_tcp_peer(&stream, peer)?;
3454+
}
34333455
if let Err(error) = stream.set_nodelay(true) {
34343456
tracing::debug!(%error, "Failed to set boundary TCP_NODELAY");
34353457
}
@@ -3442,6 +3464,42 @@ mod linux {
34423464
}
34433465
}
34443466

3467+
// Traffic the workload can reach the listener with arrives over `lo`, so one
3468+
// end is loopback or the two ends share an address. A supervisor runs in
3469+
// another pod and presents a different address. The peer comes from `accept`
3470+
// because `getpeername` fails once that peer has reset.
3471+
fn reject_workload_tcp_peer(
3472+
stream: &std::net::TcpStream,
3473+
peer: std::net::SocketAddr,
3474+
) -> io::Result<()> {
3475+
let denied = || io::Error::from_raw_os_error(libc::EACCES);
3476+
let peer = peer.ip().to_canonical();
3477+
let local = stream.local_addr().map_err(|_| denied())?.ip().to_canonical();
3478+
if peer.is_loopback() || local.is_loopback() || peer == local {
3479+
return Err(denied());
3480+
}
3481+
Ok(())
3482+
}
3483+
3484+
fn report_unavailable_ingress_filter() {
3485+
tracing::warn!(
3486+
"Kernel denied SO_ATTACH_FILTER on the boundary control listener; rejecting \
3487+
same-host control peers on accept instead"
3488+
);
3489+
openshell_ocsf::ocsf_emit!(
3490+
openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx())
3491+
.severity(openshell_ocsf::SeverityId::Low)
3492+
.status(openshell_ocsf::StatusId::Failure)
3493+
.state(openshell_ocsf::StateId::Disabled, "unavailable")
3494+
.message(
3495+
"Boundary loopback-ingress filter unavailable (SO_ATTACH_FILTER denied \
3496+
with EPERM); control listener rejects same-host peers on accept"
3497+
.to_string()
3498+
)
3499+
.build()
3500+
);
3501+
}
3502+
34453503
fn reject_workload_unix_peer(stream: &std::os::unix::net::UnixStream) -> io::Result<()> {
34463504
let mut credentials = libc::ucred {
34473505
pid: 0,
@@ -5150,22 +5208,94 @@ mod linux {
51505208
.tcp_local_addr()
51515209
.expect("TLS listener address")
51525210
.port();
5153-
// Loopback and the host's own address both arrive on `lo`; the
5154-
// dropped SYN never completes a handshake.
5155-
let result = std::net::TcpStream::connect_timeout(
5211+
// The filter drops the SYN; without it the handshake completes and
5212+
// `accept` refuses the peer instead.
5213+
let _client = std::net::TcpStream::connect_timeout(
51565214
&std::net::SocketAddr::from(([127, 0, 0, 1], port)),
51575215
Duration::from_millis(300),
51585216
);
5159-
assert!(
5160-
result.is_err(),
5161-
"loopback client reached the control listener"
5162-
);
51635217
assert!(matches!(
51645218
listener.accept().map(|_| ()),
5165-
Err(error) if error.kind() == io::ErrorKind::WouldBlock
5219+
Err(error) if matches!(
5220+
error.kind(),
5221+
io::ErrorKind::WouldBlock | io::ErrorKind::PermissionDenied
5222+
)
51665223
));
51675224
}
51685225

5226+
/// A control listener in accept-time mode, bound to loopback so a test
5227+
/// client can actually reach it.
5228+
fn accept_time_control_listener(
5229+
directory: &Path,
5230+
name: &str,
5231+
) -> (ControlListener, std::net::SocketAddr) {
5232+
let (server_tls, _client_tls) = stage_test_tls(directory, name);
5233+
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind listener");
5234+
listener.set_nonblocking(true).expect("nonblocking listener");
5235+
let address = listener.local_addr().expect("listener address");
5236+
let server_config =
5237+
Arc::new(load_tls_server_config(&server_tls).expect("TLS server config"));
5238+
(
5239+
ControlListener::Tcp {
5240+
listener,
5241+
server_config,
5242+
reject_local_peers: true,
5243+
},
5244+
address,
5245+
)
5246+
}
5247+
5248+
fn accept_pending(listener: &ControlListener) -> io::Result<()> {
5249+
let deadline = std::time::Instant::now() + Duration::from_secs(2);
5250+
loop {
5251+
match listener.accept() {
5252+
Err(error)
5253+
if error.kind() == io::ErrorKind::WouldBlock
5254+
&& std::time::Instant::now() < deadline =>
5255+
{
5256+
std::thread::sleep(Duration::from_millis(5));
5257+
}
5258+
other => return other.map(|_| ()),
5259+
}
5260+
}
5261+
}
5262+
5263+
#[test]
5264+
fn accept_refuses_a_same_host_control_peer_without_the_ingress_filter() {
5265+
let directory = tempfile::tempdir().expect("temporary directory");
5266+
let (listener, address) = accept_time_control_listener(directory.path(), "fallback");
5267+
let _client = std::net::TcpStream::connect(address).expect("connect loopback client");
5268+
let error = accept_pending(&listener).expect_err("loopback peer refused");
5269+
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
5270+
}
5271+
5272+
#[test]
5273+
fn accept_survives_a_control_peer_that_resets_before_it_is_accepted() {
5274+
let directory = tempfile::tempdir().expect("temporary directory");
5275+
let (listener, address) = accept_time_control_listener(directory.path(), "reset");
5276+
let client = socket2::Socket::new(
5277+
socket2::Domain::for_address(address),
5278+
socket2::Type::STREAM,
5279+
Some(socket2::Protocol::TCP),
5280+
)
5281+
.expect("client socket");
5282+
client.connect(&address.into()).expect("connect");
5283+
// Zero linger makes the close an RST while the connection is still
5284+
// queued, which is what makes `getpeername` fail with ENOTCONN.
5285+
client.set_linger(Some(Duration::ZERO)).expect("zero linger");
5286+
drop(client);
5287+
let kind = accept_pending(&listener)
5288+
.expect_err("reset peer refused")
5289+
.kind();
5290+
assert!(
5291+
matches!(
5292+
kind,
5293+
io::ErrorKind::PermissionDenied | io::ErrorKind::WouldBlock
5294+
),
5295+
"reset peer produced a listener-fatal error: {kind:?}"
5296+
);
5297+
}
5298+
51695299
#[test]
51705300
fn tls_listener_preserves_session_when_control_switches_to_async_streaming() {
51715301
let directory = tempfile::tempdir().expect("temporary directory");

0 commit comments

Comments
 (0)