Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions crates/sandlock-core/src/network/connect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use std::os::unix::io::{AsRawFd, RawFd};
use std::sync::Arc;

use crate::seccomp::ctx::SupervisorCtx;
use crate::seccomp::notif::{read_child_mem, NotifAction};
use crate::seccomp::notif::NotifAction;
use crate::sys::structs::{SeccompNotif, ECONNREFUSED};

use super::materialize::{
Expand Down Expand Up @@ -42,9 +42,9 @@ pub(super) async fn connect_on_behalf(

// 1. Copy sockaddr from child memory
let addr_bytes =
match read_child_mem(notif_fd, notif.id, notif.pid, addr_ptr, addr_len as usize) {
match super::read_sockaddr(notif_fd, notif.id, notif.pid, addr_ptr, addr_len as usize) {
Ok(b) => b,
Err(_) => return NotifAction::Errno(libc::EIO),
Err(e) => return NotifAction::Errno(e),
};

// 2. Check destination against the per-protocol endpoint allowlist.
Expand Down
33 changes: 32 additions & 1 deletion crates/sandlock-core/src/network/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use std::os::unix::io::RawFd;
use std::sync::Arc;

use crate::seccomp::ctx::SupervisorCtx;
use crate::seccomp::notif::NotifAction;
use crate::seccomp::notif::{read_child_mem, NotifAction};
use crate::sys::structs::SeccompNotif;

mod connect;
Expand All @@ -48,6 +48,37 @@ pub use rules::{
use connect::connect_on_behalf;
use send::{sendmmsg_on_behalf, sendmsg_on_behalf, sendto_on_behalf};

/// Largest sockaddr length we copy from the child when gating a `connect`/
/// `sendto`/`sendmsg` destination. The seccomp-notify trap fires at syscall
/// entry, *before* the kernel's own `addrlen > sizeof(sockaddr_storage)`
/// (`EINVAL`) check, so a child can pass `addr_len`/`msg_namelen` up to
/// `u32::MAX`. Reading that verbatim into `vec![0u8; len]` would let the child
/// force a multi-GiB supervisor allocation (OOM / alloc-abort of the monitor)
/// for an address that is at most this many bytes. Every legitimate sockaddr
/// fits in `sizeof(sockaddr_storage)`, so a larger length is rejected before the
/// read (see [`read_sockaddr`]).
const MAX_SOCKADDR_LEN: usize = std::mem::size_of::<libc::sockaddr_storage>();

/// Copy a sockaddr from child memory, rejecting an oversized length *before* the
/// allocation. `len` is child-controlled (`addr_len` / `msg_namelen`, a `u32`),
/// and the trap fires before the kernel's `addrlen > sizeof(sockaddr_storage)`
/// check, so an uncapped read would let the child force a multi-GiB supervisor
/// allocation. A length larger than a `sockaddr_storage` cannot address a valid
/// sockaddr, so it fails closed with `EINVAL` (matching what the kernel would
/// return) rather than being silently truncated; a read fault maps to `EIO`.
pub(super) fn read_sockaddr(
notif_fd: RawFd,
id: u64,
pid: u32,
ptr: u64,
len: usize,
) -> Result<Vec<u8>, i32> {
if len > MAX_SOCKADDR_LEN {
return Err(libc::EINVAL);
}
read_child_mem(notif_fd, id, pid, ptr, len).map_err(|_| libc::EIO)
}

// ============================================================
// query_socket_protocol — derive the rule Protocol from a fd via getsockopt
// ============================================================
Expand Down
12 changes: 6 additions & 6 deletions crates/sandlock-core/src/network/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ pub(super) async fn sendto_on_behalf(

// 1. Copy sockaddr from child memory (small: 16-28 bytes)
let addr_bytes =
match read_child_mem(notif_fd, notif.id, notif.pid, addr_ptr, addr_len as usize) {
match super::read_sockaddr(notif_fd, notif.id, notif.pid, addr_ptr, addr_len as usize) {
Ok(b) => b,
Err(_) => return NotifAction::Errno(libc::EIO),
Err(e) => return NotifAction::Errno(e),
};

// 2. Check (ip, port) against the per-protocol endpoint allowlist.
Expand Down Expand Up @@ -241,9 +241,9 @@ fn prescan_msghdr(
if hdr.connected() {
return PrescanResult::ContinueWholeCall;
}
let addr_bytes = match read_child_mem(notif_fd, notif.id, notif.pid, hdr.name_ptr, hdr.namelen as usize) {
let addr_bytes = match super::read_sockaddr(notif_fd, notif.id, notif.pid, hdr.name_ptr, hdr.namelen as usize) {
Ok(b) => b,
Err(_) => return PrescanResult::Errno(libc::EIO),
Err(e) => return PrescanResult::Errno(e),
};
if parse_ip_from_sockaddr(&addr_bytes).is_none() {
return PrescanResult::ContinueWholeCall;
Expand Down Expand Up @@ -291,9 +291,9 @@ async fn send_msghdr_on_behalf(
let addr_bytes = if connected {
Vec::new()
} else {
match read_child_mem(notif_fd, notif.id, notif.pid, hdr.name_ptr, hdr.namelen as usize) {
match super::read_sockaddr(notif_fd, notif.id, notif.pid, hdr.name_ptr, hdr.namelen as usize) {
Ok(b) => b,
Err(_) => return Err(libc::EIO),
Err(e) => return Err(e),
}
};
if !connected {
Expand Down
4 changes: 2 additions & 2 deletions crates/sandlock-core/src/network/unix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ pub(super) fn unix_sendmsg_gate(
return None; // connected socket: no address to gate
}
let addr_bytes =
read_child_mem(notif_fd, notif.id, notif.pid, hdr.name_ptr, hdr.namelen as usize).ok()?;
super::read_sockaddr(notif_fd, notif.id, notif.pid, hdr.name_ptr, hdr.namelen as usize).ok()?;
// None unless this is a NAMED AF_UNIX target; IP/abstract fall through.
let path = named_unix_socket_path(&addr_bytes)?;

Expand Down Expand Up @@ -271,7 +271,7 @@ pub(super) fn mmsg_entry_named_unix_path(
return None;
}
let addr_bytes =
read_child_mem(notif_fd, notif.id, notif.pid, hdr.name_ptr, hdr.namelen as usize).ok()?;
super::read_sockaddr(notif_fd, notif.id, notif.pid, hdr.name_ptr, hdr.namelen as usize).ok()?;
named_unix_socket_path(&addr_bytes)
}

Expand Down
53 changes: 53 additions & 0 deletions crates/sandlock-core/tests/integration/test_network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,59 @@ async fn test_udp_rule_scopes_destination_by_host() {
assert_eq!(blocked, "ERR:111", "sendto to disallowed host should ECONNREFUSED");
}

/// Regression for the uncapped-sockaddr-length DoS: a child can pass
/// `addr_len = 0xFFFFFFFF` to `sendto`. The seccomp-notify trap fires before the
/// kernel's `addrlen > sizeof(sockaddr_storage) -> EINVAL` check, so without the
/// `read_sockaddr` guard the supervisor did `vec![0u8; 0xFFFFFFFF]` (~4 GiB) and
/// could OOM/abort, taking down every sandbox. The child sends with a bogus 4 GiB
/// `addr_len` via raw `libc.sendto`. The supervisor must (a) survive — the whole
/// run completes — and (b) reject the oversized length with `EINVAL` (22),
/// matching the kernel, rather than reading it (or silently truncating). The
/// destination gate never runs, so both the allowed and blocked host fail the
/// same way.
#[tokio::test]
async fn test_sendto_huge_addrlen_rejected_with_einval() {
let out = temp_file("huge-addrlen");

let policy = base_policy()
.net_allow("udp://127.0.0.1:53")
.build()
.unwrap();

let script = format!(concat!(
"import ctypes, socket, struct, os\n",
"libc = ctypes.CDLL('libc.so.6', use_errno=True)\n",
"libc.sendto.restype = ctypes.c_ssize_t\n",
"def sai(ip, port):\n",
" return struct.pack('<H', socket.AF_INET) + struct.pack('!H', port) + socket.inet_aton(ip) + b'\\x00'*8\n",
"s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n",
"buf = ctypes.create_string_buffer(b'x')\n",
"def send(ip):\n",
" a = ctypes.create_string_buffer(sai(ip, 53))\n",
// addrlen = 0xFFFFFFFF: without the clamp this forces a ~4 GiB supervisor alloc.
" ctypes.set_errno(0)\n",
" r = libc.sendto(s.fileno(), buf, 1, 0, a, 0xFFFFFFFF)\n",
" return 'OK' if r >= 0 else f'ERR:{{ctypes.get_errno()}}'\n",
"allowed = send('127.0.0.1')\n",
"blocked = send('1.1.1.1')\n",
"open('{out}', 'w').write(allowed + '|' + blocked)\n",
"s.close()\n",
), out = out.display());

let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script])
.await.unwrap();
// The run completing at all is the core assertion: an OOM-aborted supervisor
// would kill the child instead of letting it finish.
assert!(result.success(), "supervisor should survive a 4 GiB addr_len; exit={:?}", result.code());

let got = std::fs::read_to_string(&out).unwrap_or_default();
let _ = std::fs::remove_file(&out);
// EINVAL (22) for both: the oversized addr_len is rejected before the
// destination gate, so allowed vs blocked is never reached.
assert_eq!(got, "ERR:22|ERR:22",
"a bogus 4 GiB addr_len must be rejected with EINVAL, matching the kernel");
}

/// `udp://*:*` is the "any UDP destination" gate — it should not regress
/// after Phase 2's per-protocol routing. Both sendtos succeed.
#[tokio::test]
Expand Down
Loading