From 57c08bf74b098d0d1daa9d694c9ef1b68e99a37c Mon Sep 17 00:00:00 2001 From: dzerik Date: Tue, 7 Jul 2026 14:04:19 +0300 Subject: [PATCH 1/2] fix(net): cap child-controlled sockaddr length before reading it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sandboxed child can pass an arbitrary sockaddr length on connect/sendto/ sendmsg/sendmmsg (addr_len / msg_namelen are u32, up to 0xFFFFFFFF). The seccomp-notify trap fires at syscall entry, before the kernel's own `addrlen > sizeof(sockaddr_storage)` EINVAL check, so the length reached read_child_mem uncapped and read_child_mem_vm did `vec![0u8; len]` — letting the child force a multi-GiB allocation in the supervisor (OOM, or an alloc-abort of the whole monitor under RLIMIT_AS / overcommit=2 / low RAM) for a sockaddr that is at most 128 bytes. Every other child-controlled read is already bounded (MAX_SEND_BUF 64 MiB, MAX_CONTROL_BUF 16 KiB, iovlen 1024, vlen 256); the sockaddr length was the one gap. Clamp each of the six sockaddr reads to MAX_SOCKADDR_LEN (sizeof(sockaddr_storage) = 128) — 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, so no legitimate call is affected. --- crates/sandlock-core/src/network.rs | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/crates/sandlock-core/src/network.rs b/crates/sandlock-core/src/network.rs index b5446ea..f8fc6f6 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), } From 49b52803c42cfec5f4073bc9f35a7f9ca7f7f34a Mon Sep 17 00:00:00 2001 From: dzerik Date: Tue, 7 Jul 2026 14:21:38 +0300 Subject: [PATCH 2/2] test(net): regression for the sockaddr-length OOM cap A child sends to the allowed and the blocked host with a bogus 4 GiB `addr_len` via raw `libc.sendto`. Without the clamp the supervisor allocated ~4 GiB and could OOM/abort; the test asserts the run completes (the supervisor survives) and that gating still holds on the real address bytes (allowed -> sent, blocked -> ECONNREFUSED) despite the huge claimed length. Verified passing against a system Python (the sandbox suite needs the interpreter stdlib under an fs-read grant). --- .../tests/integration/test_network.rs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/sandlock-core/tests/integration/test_network.rs b/crates/sandlock-core/tests/integration/test_network.rs index 2e47e3d..b51df9a 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]