From 567e996ff42b6ff72d80f91ed8c33eec022adcca Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Sat, 12 Sep 2026 20:50:40 -0700 Subject: [PATCH 1/2] seccomp: enforce the TCP bind allowlist on the on-behalf bind path The bind allowlist was enforced only by Landlock BIND_TCP rules in the child. Any network supervision (net_allow, net_deny, the HTTP ACL, a policy function, port_remap) moves bind() onto the on-behalf path, where the supervisor binds a dup of the child's socket outside the child's Landlock domain. The kernel rules never saw the bind and the handler checked only the denylist, so the allowlist and the default deny-all silently stopped applying as soon as the sandbox also needed egress. The supervisor now carries the allowlist and refuses TCP binds outside it with EACCES, including bind(0), matching Landlock's behaviour on the direct path. Four Python port-remap tests bound ports without any allowlist and passed only because of the bypass; they now declare one. Signed-off-by: Cong Wang --- README.md | 4 +- crates/sandlock-core/src/port_remap.rs | 35 +++++--- crates/sandlock-core/src/sandbox.rs | 16 +++- crates/sandlock-core/src/seccomp/state.rs | 7 ++ .../tests/integration/test_network.rs | 85 +++++++++++++++++++ python/tests/test_sandbox.py | 8 +- 6 files changed, 133 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index e458a902..ed379c8f 100644 --- a/README.md +++ b/README.md @@ -757,7 +757,9 @@ comma-separated list of single ports or inclusive `lo-hi` ranges (e.g. allows binding any port, including an ephemeral `bind(0)`; it cannot be mixed with port lists (repeating the bare wildcard is fine). Landlock enforces the allowlist (TCP only; the wildcard simply leaves Landlock's `BIND_TCP` hook -unhandled); `--port-remap` adds on-behalf virtualization for binding. +unhandled). When network supervision is active (`--net-allow`, `--net-deny`, +`--http-allow`, `--port-remap`, a policy function) `bind()` runs on the +supervisor's on-behalf path, which enforces the same allowlist. `--net-deny-bind ` is the inverse: default-allow binding, deny the listed TCP ports (same port syntax, mutually exclusive with `--net-allow-bind`). Because Landlock is allowlist-only, a deny-bind relaxes diff --git a/crates/sandlock-core/src/port_remap.rs b/crates/sandlock-core/src/port_remap.rs index 212de355..28d73fab 100644 --- a/crates/sandlock-core/src/port_remap.rs +++ b/crates/sandlock-core/src/port_remap.rs @@ -169,20 +169,21 @@ pub(crate) async fn handle_bind( return NotifAction::Errno(libc::EACCES); } - // Non-IP family or ephemeral (port == 0): bind verbatim — nothing to - // track or remap. extract_port returns None for non-IP families and - // for truncated buffers; in both cases the kernel will validate. - let virtual_port = match extract_port(&bytes) { - Some(p) if p != 0 => p, - _ => return bind_verbatim(&dup_fd, &bytes, addr_len), - }; - - // --net-deny-bind: reject binding a denied TCP port. Only TCP is gated - // (mirroring --net-allow-bind); UDP/other binds are unaffected. The - // SO_PROTOCOL probe is skipped entirely when the denylist is empty. - let denied = { - let ns = network.lock().await; - !ns.bind_deny_ports.is_empty() && ns.bind_deny_ports.contains(&virtual_port) + // Non-IP family or truncated buffer: extract_port returns None and the + // kernel validates the bind. + let ip_port = extract_port(&bytes); + + // TCP bind allowlist / denylist. Only TCP is gated (Landlock's BIND_TCP + // is TCP-only); UDP/other binds are unaffected. A port-0 bind is refused + // under an allowlist because Landlock refuses it too: only `'*'` can + // express "any ephemeral port". + let denied = match ip_port { + Some(port) => { + let ns = network.lock().await; + ns.bind_allow_ports.as_ref().is_some_and(|allow| !allow.contains(&port)) + || ns.bind_deny_ports.contains(&port) + } + None => false, }; if denied && crate::network::query_socket_protocol(dup_fd.as_raw_fd()) @@ -191,6 +192,12 @@ pub(crate) async fn handle_bind( return NotifAction::Errno(libc::EACCES); } + // Ephemeral (port == 0): bind verbatim, nothing to track or remap. + let virtual_port = match ip_port { + Some(p) if p != 0 => p, + _ => return bind_verbatim(&dup_fd, &bytes, addr_len), + }; + // Pick a first-attempt port: cached real port if known, else the // virtual port itself. The cached real port keeps repeat binds of // the same virtual port consistent across the sandbox; the virtual diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index a1d19946..628199b3 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -422,9 +422,10 @@ pub struct Sandbox { /// Mutually exclusive with `net_allow`. pub net_deny: Vec, /// `--net-allow-bind`: TCP ports the sandbox may bind (default-deny - /// allowlist, Landlock-enforced; `All` leaves Landlock's `BIND_TCP` - /// hook unhandled so any port may be bound). Mutually exclusive with - /// `net_deny_bind`. + /// allowlist, enforced by Landlock on the direct path and by the + /// on-behalf `bind()` handler under network supervision; `All` leaves + /// Landlock's `BIND_TCP` hook unhandled so any port may be bound). + /// Mutually exclusive with `net_deny_bind`. pub net_allow_bind: BindPorts, /// `--net-deny-bind`: TCP ports the sandbox may NOT bind (default-allow /// denylist, enforced on the on-behalf `bind()` path). Mutually @@ -2135,6 +2136,15 @@ impl Sandbox { net_state.http_acl_ports = self.http_ports.iter().copied().collect(); net_state.http_acl_orig_dest = self.rt().http_acl_handle.as_ref().map(|h| h.orig_dest.clone()); net_state.bind_deny_ports = self.net_deny_bind.iter().copied().collect(); + let net_tcp_active = self.active_protections()? + .into_iter() + .any(|(p, s)| p == Protection::NetTcp && s == ProtectionStatus::Active); + net_state.bind_allow_ports = match &self.net_allow_bind { + BindPorts::Ports(ports) if net_tcp_active && self.net_deny_bind.is_empty() => { + Some(ports.iter().copied().collect()) + } + _ => None, + }; if let Some(cb) = self.rt_mut().on_bind.take() { net_state.port_map.on_bind = Some(cb); } diff --git a/crates/sandlock-core/src/seccomp/state.rs b/crates/sandlock-core/src/seccomp/state.rs index 14217d46..6f33901c 100644 --- a/crates/sandlock-core/src/seccomp/state.rs +++ b/crates/sandlock-core/src/seccomp/state.rs @@ -468,6 +468,12 @@ pub struct NetworkState { /// denylist). The on-behalf `bind()` handler rejects a TCP bind to any /// port in this set with `EACCES`; empty = no bind denylist. pub bind_deny_ports: HashSet, + /// `--net-allow-bind`: TCP ports the sandbox may bind. The on-behalf + /// `bind()` runs in the supervisor, outside the child's Landlock domain, + /// so the kernel rules never see it and this set is the only enforcer + /// there. `None` = unrestricted (`'*'` or NetTcp inactive); `Some(empty)` + /// = the default deny-all. + pub bind_allow_ports: Option>, /// Per-PID network overrides from policy_fn (IP-only via the legacy /// `restrict_network(ips)` API; any port is permitted to listed IPs). pub pid_ip_overrides: std::sync::Arc>>>, @@ -487,6 +493,7 @@ impl NetworkState { icmp_policy: crate::seccomp::notif::NetworkPolicy::Unrestricted, port_map: crate::port_remap::PortMap::new(), bind_deny_ports: HashSet::new(), + bind_allow_ports: None, pid_ip_overrides: std::sync::Arc::new(std::sync::RwLock::new(HashMap::new())), http_acl_addr: None, http_acl_ports: HashSet::new(), diff --git a/crates/sandlock-core/tests/integration/test_network.rs b/crates/sandlock-core/tests/integration/test_network.rs index 18a1ce3d..f55f47b8 100644 --- a/crates/sandlock-core/tests/integration/test_network.rs +++ b/crates/sandlock-core/tests/integration/test_network.rs @@ -1276,3 +1276,88 @@ async fn test_sendmmsg_blocking_entry_defers_and_delivers_under_net_policy() { ); assert_eq!(got, N, "peer must receive all {N} bytes of the deferred batch entry"); } + +/// Any network supervision (`--net-allow`, `--port-remap`, ...) moves +/// `bind()` onto the on-behalf path, where the supervisor binds outside the +/// child's Landlock domain. The bind allowlist must still hold there: +/// listed ports bind, unlisted ports and ephemeral `bind(0)` fail with +/// EACCES, and UDP is untouched. +#[tokio::test] +async fn test_net_allow_bind_enforced_on_behalf() { + fn free_port() -> u16 { + TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port() + } + let allowed = free_port(); + let mut other = free_port(); + while other == allowed { + other = free_port(); + } + + let script = format!(concat!( + "import socket, json\n", + "res = {{}}\n", + "def tcp(key, port):\n", + " s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n", + " s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n", + " try:\n", + " s.bind(('127.0.0.1', port)); res[key] = 'ok'\n", + " except PermissionError:\n", + " res[key] = 'eacces'\n", + " except OSError as e:\n", + " res[key] = 'err:%d' % e.errno\n", + " s.close()\n", + "tcp('allowed', {allowed})\n", + "tcp('other', {other})\n", + "tcp('ephemeral', 0)\n", + "u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n", + "try:\n", + " u.bind(('127.0.0.1', {other})); res['udp'] = 'ok'\n", + "except OSError as e:\n", + " res['udp'] = 'err:%d' % e.errno\n", + "open('{out}', 'w').write(json.dumps(res))\n", + ), allowed = allowed, other = other, out = "{out}"); + + let supervised = [ + ("net_allow", base_policy().net_allow("udp://*").net_allow("127.0.0.1:1")), + ("net_deny", base_policy().net_deny("10.0.0.0/8")), + ("port_remap", base_policy().net_allow("udp://*").port_remap(true)), + ]; + for (label, builder) in supervised { + let out = temp_file(&format!("allowbind_{label}")); + let policy = builder.net_allow_bind_port(allowed).build().unwrap(); + let script = script.replace("{out}", &out.display().to_string()); + let result = policy.clone() + .run_interactive(&["python3", "-c", &script]).await.unwrap(); + assert!(result.success(), "{label}: exit={:?}", result.code()); + let content = std::fs::read_to_string(&out).unwrap_or_default(); + let _ = std::fs::remove_file(&out); + assert!(content.contains("\"allowed\": \"ok\""), "{label}: listed port must bind; got: {content}"); + assert!(content.contains("\"other\": \"eacces\""), "{label}: unlisted port must fail with EACCES; got: {content}"); + assert!(content.contains("\"ephemeral\": \"eacces\""), "{label}: bind(0) must fail with EACCES; got: {content}"); + assert!(content.contains("\"udp\": \"ok\""), "{label}: UDP bind must be unaffected; got: {content}"); + } +} + +/// With no allowlist at all, the default is deny-every-TCP-bind. That must +/// hold on the on-behalf path too, not only under Landlock. +#[tokio::test] +async fn test_default_bind_deny_enforced_on_behalf() { + let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); + let out = temp_file("defaultbind"); + let policy = base_policy().port_remap(true).build().unwrap(); + let script = format!(concat!( + "import socket\n", + "s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n", + "try:\n", + " s.bind(('127.0.0.1', {port})); r = 'ok'\n", + "except PermissionError:\n", + " r = 'eacces'\n", + "open('{out}', 'w').write(r)\n", + ), port = port, out = out.display()); + let result = policy.clone() + .run_interactive(&["python3", "-c", &script]).await.unwrap(); + assert!(result.success(), "exit={:?}", result.code()); + let content = std::fs::read_to_string(&out).unwrap_or_default(); + let _ = std::fs::remove_file(&out); + assert_eq!(content, "eacces", "TCP bind with no allowlist must fail under port_remap"); +} diff --git a/python/tests/test_sandbox.py b/python/tests/test_sandbox.py index c9e27754..166790de 100644 --- a/python/tests/test_sandbox.py +++ b/python/tests/test_sandbox.py @@ -581,7 +581,7 @@ def test_two_sandboxes_same_virtual_port(self): "print(s.getsockname()[1]); " "s.close()" ) - policy = _policy(port_remap=True) + policy = _policy(port_remap=True, net_allow_bind=[8080]) r1 = policy.run(["python3", "-c", code]) r2 = policy.run(["python3", "-c", code]) @@ -601,7 +601,7 @@ def test_getsockname_returns_virtual_port(self): "print(name[0], name[1]); " "s.close()" ) - policy = _policy(port_remap=True) + policy = _policy(port_remap=True, net_allow_bind=[4000]) result = policy.run(["python3", "-c", code]) assert result.success @@ -618,7 +618,7 @@ def test_ephemeral_port_not_remapped(self): "print(s.getsockname()[1]); " "s.close()" ) - policy = _policy(port_remap=True) + policy = _policy(port_remap=True, net_allow_bind=["*"]) result = policy.run(["python3", "-c", code]) assert result.success @@ -633,7 +633,7 @@ def test_ipv6_bind_remapped(self): "print(s.getsockname()[1]); " "s.close()" ) - policy = _policy(port_remap=True) + policy = _policy(port_remap=True, net_allow_bind=[5000]) result = policy.run(["python3", "-c", code]) assert result.success From 25210f0adfa81f7381788ea793017d2568ec3f1f Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Sat, 12 Sep 2026 21:13:15 -0700 Subject: [PATCH 2/2] learn: allow any bind in the observation sandbox Learn mode grants wildcard egress but never declared a bind allowlist, so its binds only succeeded through the on-behalf bypass that the previous commit closed. Observation has to see every bind the program attempts, so the allowlist is now the wildcard. Signed-off-by: Cong Wang --- crates/sandlock-cli/src/learn.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index 268ec5d5..7742991d 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -600,6 +600,9 @@ pub async fn run(args: LearnArgs) -> Result<()> { // A scheme-less "*" covers TCP and UDP; ICMP always needs its own rule. .net_allow("*") .net_allow("icmp://*") + // Observation must see every bind the program attempts, so nothing + // may refuse one before it is recorded. + .net_allow_bind("*") .max_memory(sandlock_core::sandbox::ByteSize(1 << 43)) // 8 TiB .policy_fn(move |event, _ctx| observer_cb.on_event(event));