Skip to content

Commit 066ed45

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. Some kernels gate 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 EPERM as "filter unavailable" and move the check to accept time. Any peer the workload can reach the listener from arrives over `lo`, so its source address is either loopback or the address the connection was accepted on; a supervisor runs in a different pod and presents a different address. The serve loop already drops PermissionDenied accepts, which is the same path the Unix peer check uses, and the control channel still requires the per-sandbox mTLS certificate. Kernels that permit the filter keep the stronger standing guarantee. Signed-off-by: divesh <dgude@nvidia.com>
1 parent 834b79a commit 066ed45

2 files changed

Lines changed: 67 additions & 15 deletions

File tree

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,21 @@ pub fn reject_loopback_ingress(fd: impl AsFd) -> io::Result<()> {
5757
reject_ingress_interface(fd, index)
5858
}
5959

60+
/// Install loopback-ingress rejection, reporting whether the kernel allowed it.
61+
///
62+
/// Kernels that gate `SO_ATTACH_FILTER` on `CAP_NET_ADMIN` yield `Ok(false)`.
63+
///
64+
/// # Errors
65+
///
66+
/// Returns the kernel error for any failure other than `EPERM`.
67+
pub fn try_reject_loopback_ingress(fd: impl AsFd) -> io::Result<bool> {
68+
match reject_loopback_ingress(fd) {
69+
Ok(()) => Ok(true),
70+
Err(error) if error.raw_os_error() == Some(libc::EPERM) => Ok(false),
71+
Err(error) => Err(error),
72+
}
73+
}
74+
6075
fn reject_ingress_interface(fd: impl AsFd, index: u32) -> io::Result<()> {
6176
// Ancillary loads use the documented negative offset encoding.
6277
let ifindex_offset = (libc::SKF_AD_OFF + libc::SKF_AD_IFINDEX).cast_unsigned();

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

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3277,6 +3277,7 @@ mod linux {
32773277
Tcp {
32783278
listener: std::net::TcpListener,
32793279
server_config: Arc<rustls::ServerConfig>,
3280+
reject_local_peers: bool,
32803281
},
32813282
}
32823283

@@ -3305,12 +3306,13 @@ mod linux {
33053306
})
33063307
}
33073308
BoundaryListenerConfig::TlsTcp { address, tls } => {
3308-
let listener = Self::bind_tcp(*address)?;
3309+
let (listener, reject_local_peers) = Self::bind_tcp(*address)?;
33093310
listener.set_nonblocking(true)?;
33103311
let server_config = Arc::new(load_tls_server_config(tls)?);
33113312
Ok(Self::Tcp {
33123313
listener,
33133314
server_config,
3315+
reject_local_peers,
33143316
})
33153317
}
33163318
}
@@ -3319,23 +3321,33 @@ mod linux {
33193321
/// Bind the TCP control listener, dropping loopback-interface ingress
33203322
/// before it listens so workload sockets cannot reach it through
33213323
/// loopback or the pod's own address. Configuration rejects loopback
3322-
/// addresses; tests bind them without the filter.
3323-
fn bind_tcp(address: std::net::SocketAddr) -> io::Result<std::net::TcpListener> {
3324+
/// addresses; tests bind them without the filter. Returns whether the
3325+
/// kernel refused the filter, leaving that check to `accept`.
3326+
fn bind_tcp(address: std::net::SocketAddr) -> io::Result<(std::net::TcpListener, bool)> {
33243327
let socket = socket2::Socket::new(
33253328
socket2::Domain::for_address(address),
33263329
socket2::Type::STREAM,
33273330
Some(socket2::Protocol::TCP),
33283331
)?;
33293332
socket.set_cloexec(true)?;
33303333
socket.set_reuse_address(true)?;
3334+
let mut reject_local_peers = false;
33313335
if !address.ip().is_loopback() {
3332-
openshell_isolation_interface::linux::socket_confinement::reject_loopback_ingress(
3333-
&socket,
3334-
)?;
3336+
let filtered =
3337+
openshell_isolation_interface::linux::socket_confinement::try_reject_loopback_ingress(
3338+
&socket,
3339+
)?;
3340+
reject_local_peers = !filtered;
3341+
if reject_local_peers {
3342+
tracing::warn!(
3343+
"Kernel refused the boundary loopback-ingress filter; rejecting \
3344+
same-host control peers on accept instead"
3345+
);
3346+
}
33353347
}
33363348
socket.bind(&address.into())?;
33373349
socket.listen(128)?;
3338-
Ok(socket.into())
3350+
Ok((socket.into(), reject_local_peers))
33393351
}
33403352

33413353
fn bind_vsock(port: u32) -> io::Result<OwnedFd> {
@@ -3428,8 +3440,12 @@ mod linux {
34283440
Self::Tcp {
34293441
listener,
34303442
server_config,
3443+
reject_local_peers,
34313444
} => {
34323445
let (stream, _) = listener.accept()?;
3446+
if *reject_local_peers {
3447+
reject_workload_tcp_peer(&stream)?;
3448+
}
34333449
if let Err(error) = stream.set_nodelay(true) {
34343450
tracing::debug!(%error, "Failed to set boundary TCP_NODELAY");
34353451
}
@@ -3442,6 +3458,18 @@ mod linux {
34423458
}
34433459
}
34443460

3461+
// Traffic the workload can reach the listener with arrives over `lo`, so its
3462+
// source is loopback or the address it was accepted on. A supervisor runs in
3463+
// another pod and presents a different address.
3464+
fn reject_workload_tcp_peer(stream: &std::net::TcpStream) -> io::Result<()> {
3465+
let peer = stream.peer_addr()?.ip().to_canonical();
3466+
let local = stream.local_addr()?.ip().to_canonical();
3467+
if peer.is_loopback() || peer == local {
3468+
return Err(io::Error::from_raw_os_error(libc::EACCES));
3469+
}
3470+
Ok(())
3471+
}
3472+
34453473
fn reject_workload_unix_peer(stream: &std::os::unix::net::UnixStream) -> io::Result<()> {
34463474
let mut credentials = libc::ucred {
34473475
pid: 0,
@@ -5150,22 +5178,31 @@ mod linux {
51505178
.tcp_local_addr()
51515179
.expect("TLS listener address")
51525180
.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(
5181+
// The filter drops the SYN; without it the handshake completes and
5182+
// `accept` refuses the peer instead.
5183+
let _client = std::net::TcpStream::connect_timeout(
51565184
&std::net::SocketAddr::from(([127, 0, 0, 1], port)),
51575185
Duration::from_millis(300),
51585186
);
5159-
assert!(
5160-
result.is_err(),
5161-
"loopback client reached the control listener"
5162-
);
51635187
assert!(matches!(
51645188
listener.accept().map(|_| ()),
5165-
Err(error) if error.kind() == io::ErrorKind::WouldBlock
5189+
Err(error) if matches!(
5190+
error.kind(),
5191+
io::ErrorKind::WouldBlock | io::ErrorKind::PermissionDenied
5192+
)
51665193
));
51675194
}
51685195

5196+
#[test]
5197+
fn accept_time_fallback_refuses_a_same_host_control_peer() {
5198+
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind listener");
5199+
let address = listener.local_addr().expect("listener address");
5200+
let _client = std::net::TcpStream::connect(address).expect("connect loopback client");
5201+
let (accepted, _) = listener.accept().expect("accept loopback client");
5202+
let error = reject_workload_tcp_peer(&accepted).expect_err("loopback peer refused");
5203+
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
5204+
}
5205+
51695206
#[test]
51705207
fn tls_listener_preserves_session_when_control_switches_to_async_streaming() {
51715208
let directory = tempfile::tempdir().expect("temporary directory");

0 commit comments

Comments
 (0)