Skip to content
Open
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
25 changes: 19 additions & 6 deletions crates/sandlock-core/src/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<libc::sockaddr_storage>();

/// 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)]
Expand Down Expand Up @@ -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),
};
Expand Down Expand Up @@ -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)?;

Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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),
};
Expand Down Expand Up @@ -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),
};
Expand Down Expand Up @@ -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),
}
Expand Down
49 changes: 49 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,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('<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);
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]
Expand Down
Loading