diff --git a/crates/sandlock-core/src/network.rs b/crates/sandlock-core/src/network.rs index b5446eab..f8fc6f64 100644 --- a/crates/sandlock-core/src/network.rs +++ b/crates/sandlock-core/src/network.rs @@ -27,6 +27,19 @@ const MAX_SEND_BUF: usize = 64 << 20; /// memory per trapped send). const MAX_CONTROL_BUF: usize = 16 << 10; +/// 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 128 bytes. Every legitimate sockaddr fits in +/// `sizeof(sockaddr_storage)`, so we clamp the read length to it — the gate +/// only needs the family and address bytes, and the actual send is either +/// re-run by the kernel (`Continue`) or performed on-behalf with our own +/// correctly-sized sockaddr. +const MAX_SOCKADDR_LEN: usize = std::mem::size_of::(); + /// An IPv4 or IPv6 address with a prefix length, used by `--net-deny` /// to match destination IPs by exact address (`/32`, `/128`) or by range. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -607,7 +620,7 @@ 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 read_child_mem(notif_fd, notif.id, notif.pid, addr_ptr, (addr_len as usize).min(MAX_SOCKADDR_LEN)) { Ok(b) => b, Err(_) => return NotifAction::Errno(libc::EIO), }; @@ -1052,7 +1065,7 @@ fn unix_sendmsg_gate( } let msg_namelen = u32::from_ne_bytes(msghdr_bytes[8..12].try_into().unwrap()); let addr_bytes = - read_child_mem(notif_fd, notif.id, notif.pid, msg_name_ptr, msg_namelen as usize).ok()?; + read_child_mem(notif_fd, notif.id, notif.pid, msg_name_ptr, (msg_namelen as usize).min(MAX_SOCKADDR_LEN)).ok()?; // None unless this is a NAMED AF_UNIX target; IP/abstract fall through. let path = named_unix_socket_path(&addr_bytes)?; @@ -1174,7 +1187,7 @@ fn mmsg_entry_named_unix_path( } let msg_namelen = u32::from_ne_bytes(hdr[8..12].try_into().unwrap()); let addr_bytes = - read_child_mem(notif_fd, notif.id, notif.pid, msg_name_ptr, msg_namelen as usize).ok()?; + read_child_mem(notif_fd, notif.id, notif.pid, msg_name_ptr, (msg_namelen as usize).min(MAX_SOCKADDR_LEN)).ok()?; named_unix_socket_path(&addr_bytes) } @@ -1329,7 +1342,7 @@ 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 read_child_mem(notif_fd, notif.id, notif.pid, addr_ptr, (addr_len as usize).min(MAX_SOCKADDR_LEN)) { Ok(b) => b, Err(_) => return NotifAction::Errno(libc::EIO), }; @@ -1524,7 +1537,7 @@ fn prescan_msghdr( return PrescanResult::ContinueWholeCall; } let msg_namelen = u32::from_ne_bytes(msghdr_bytes[8..12].try_into().unwrap()); - let addr_bytes = match read_child_mem(notif_fd, notif.id, notif.pid, msg_name_ptr, msg_namelen as usize) { + let addr_bytes = match read_child_mem(notif_fd, notif.id, notif.pid, msg_name_ptr, (msg_namelen as usize).min(MAX_SOCKADDR_LEN)) { Ok(b) => b, Err(_) => return PrescanResult::Errno(libc::EIO), }; @@ -1790,7 +1803,7 @@ async fn send_msghdr_on_behalf( let addr_bytes = if connected { Vec::new() } else { - match read_child_mem(notif_fd, notif.id, notif.pid, msg_name_ptr, msg_namelen as usize) { + match read_child_mem(notif_fd, notif.id, notif.pid, msg_name_ptr, (msg_namelen as usize).min(MAX_SOCKADDR_LEN)) { Ok(b) => b, Err(_) => return Err(libc::EIO), } diff --git a/crates/sandlock-core/tests/integration/test_network.rs b/crates/sandlock-core/tests/integration/test_network.rs index 2e47e3d4..b51df9a7 100644 --- a/crates/sandlock-core/tests/integration/test_network.rs +++ b/crates/sandlock-core/tests/integration/test_network.rs @@ -62,6 +62,55 @@ 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 > 128 -> EINVAL` check, so without the `MAX_SOCKADDR_LEN` +/// clamp the supervisor did `vec![0u8; 0xFFFFFFFF]` (~4 GiB) and could OOM/abort, +/// taking down every sandbox. Here the child sends to the allowed and the blocked +/// host with a bogus 4 GiB `addr_len` via raw `libc.sendto`. The supervisor must +/// (a) survive — the whole run completes — and (b) still gate on the real address +/// bytes despite the huge claimed length: allowed -> sent, blocked -> ECONNREFUSED. +#[tokio::test] +async fn test_sendto_huge_addrlen_does_not_oom_supervisor() { + 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('= 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); + assert_eq!(got, "OK|ERR:111", + "gating must still hold on the real address bytes despite a bogus 4 GiB addr_len"); +} + /// `udp://*:*` is the "any UDP destination" gate — it should not regress /// after Phase 2's per-protocol routing. Both sendtos succeed. #[tokio::test]