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
33 changes: 28 additions & 5 deletions crates/sandlock-cli/src/learn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,8 +285,12 @@ struct LearnObserver {
/// /proc/<pid>/exe. Pins [program].exec to an absolute path; argv[0] may
/// be relative (resolved through $PATH by execvp) and useless to `run`.
first_exe: Arc<Mutex<Option<PathBuf>>>,
/// UDP connect() calls pending confirmation by a subsequent send().
/// Key: (pid, fd). Value: formatted "udp://host:port" entry.
/// UDP connect() calls pending confirmation by a subsequent send.
/// Keyed by (TGID, fd) so a connect observed on one thread can be
/// confirmed by traffic from a sibling thread using the same fd table.
/// Connected UDP write(2) is deliberately not observed: trapping write
/// would catch the child bootstrap pipe writes before the parent receives
/// the seccomp listener fd.
pending_udp_connects: Arc<Mutex<HashMap<(u32, i64), String>>>,
}

Expand Down Expand Up @@ -417,6 +421,23 @@ impl LearnObserver {
}
}
"connect" | "sendto" | "sendmsg" | "sendmmsg" => {
// An address-less send on a connected UDP socket is how the
// glibc resolver talks to its nameserver (connect, then
// send with no sockaddr): real traffic, so it promotes the
// parked connect() the same way an addressed send does.
if event.host.is_none()
&& event.syscall != "connect"
&& event.protocol.as_deref() == Some("udp")
{
if let Some(fd) = event.fd {
let key = (tgid_of(event.pid), fd);
if let Some(pending) = self.pending_udp_connects
.lock().unwrap().remove(&key)
{
self.connects.lock().unwrap().insert(pending);
}
}
}
if let (Some(ip), Some(port), Some(proto)) = (event.host, event.port, event.protocol) {
if proto != "icmp" {
let host = if ip.is_ipv6() { format!("[{ip}]") } else { ip.to_string() };
Expand All @@ -427,16 +448,18 @@ impl LearnObserver {
// address-sorting probe (e.g. glibc getaddrinfo) and is
// discarded when the workload exits.
if let Some(fd) = event.fd {
let key = (tgid_of(event.pid), fd);
self.pending_udp_connects.lock().unwrap()
.insert((event.pid, fd), entry);
.insert(key, entry);
}
} else {
// For UDP send*, promote any pending connect() on the
// same (pid, fd) to confirmed traffic.
// same process fd to confirmed traffic.
if proto == "udp" {
if let Some(fd) = event.fd {
let key = (tgid_of(event.pid), fd);
if let Some(pending) = self.pending_udp_connects
.lock().unwrap().remove(&(event.pid, fd))
.lock().unwrap().remove(&key)
{
self.connects.lock().unwrap().insert(pending);
}
Expand Down
92 changes: 92 additions & 0 deletions crates/sandlock-cli/tests/learn_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,53 @@
// cargo test -p sandlock-cli --test learn_integration -- --test-threads=4

use std::process::Command;
use std::time::Duration;

fn sandlock_bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_sandlock"))
}

fn udp_sink(expected_datagrams: usize) -> (u16, std::thread::JoinHandle<usize>) {
let sock = std::net::UdpSocket::bind("127.0.0.1:0").expect("udp sink bind");
sock.set_read_timeout(Some(Duration::from_secs(5)))
.expect("udp sink timeout");
let port = sock.local_addr().expect("udp sink addr").port();
let handle = std::thread::spawn(move || {
let mut buf = [0_u8; 64];
let mut received = 0;
while received < expected_datagrams {
match sock.recv_from(&mut buf) {
Ok(_) => received += 1,
Err(_) => break,
}
}
received
});
(port, handle)
}

fn learn_then_run_script(profile_path: &str, script: &str) {
let learn = sandlock_bin()
.args(["learn", "-o", profile_path, "--", "python3", "-c", script])
.output()
.expect("failed to run sandlock learn");
assert!(
learn.status.success(),
"learn failed: {}",
String::from_utf8_lossy(&learn.stderr)
);

let run = sandlock_bin()
.args(["run", "--profile-file", profile_path, "--", "python3", "-c", script])
.output()
.expect("failed to run sandlock run");
assert!(
run.status.success(),
"run failed: {}",
String::from_utf8_lossy(&run.stderr)
);
}

/// Learn → run with a read-only workload.
#[test]
fn test_learn_then_run() {
Expand All @@ -32,6 +74,56 @@ fn test_learn_then_run() {
"expected output from cat /etc/hostname");
}

/// Connected UDP send: an address-less send confirms the earlier UDP connect
/// as real traffic, so the generated profile must replay.
#[test]
fn test_learn_then_run_connected_udp_send() {
let (port, sink) = udp_sink(2);
let profile = tempfile::NamedTempFile::new().expect("tempfile");
let profile_path = profile.path().to_str().unwrap().to_owned();
let script = format!(
"import socket\n\
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n\
s.connect(('127.0.0.1', {port}))\n\
s.send(b'x')\n\
s.close()\n"
);

learn_then_run_script(&profile_path, &script);
let profile_toml = std::fs::read_to_string(&profile_path).expect("profile");
assert!(
profile_toml.contains(&format!("udp://127.0.0.1:{port}")),
"expected connected UDP endpoint in profile:\n{profile_toml}"
);
assert_eq!(sink.join().unwrap(), 2, "learn and run should each send one UDP datagram");
}

/// A connect observed on one thread and a send observed on a sibling thread
/// still refer to the same process fd table.
#[test]
fn test_learn_then_run_connected_udp_send_from_thread() {
let (port, sink) = udp_sink(2);
let profile = tempfile::NamedTempFile::new().expect("tempfile");
let profile_path = profile.path().to_str().unwrap().to_owned();
let script = format!(
"import socket, threading\n\
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n\
s.connect(('127.0.0.1', {port}))\n\
t = threading.Thread(target=lambda: s.send(b'x'))\n\
t.start()\n\
t.join()\n\
s.close()\n"
);

learn_then_run_script(&profile_path, &script);
let profile_toml = std::fs::read_to_string(&profile_path).expect("profile");
assert!(
profile_toml.contains(&format!("udp://127.0.0.1:{port}")),
"expected connected UDP endpoint in profile:\n{profile_toml}"
);
assert_eq!(sink.join().unwrap(), 2, "learn and run should each send one UDP datagram");
}

/// Write path: COW isolates during learn; run creates the file for real.
#[test]
fn test_learn_then_run_write() {
Expand Down
2 changes: 1 addition & 1 deletion crates/sandlock-core/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use crate::arch;
#[cfg(test)]
use crate::sys::structs::{
AF_INET, AF_INET6, CLONE_NS_FLAGS, DEFAULT_BLOCKLIST_SYSCALLS, PR_SET_DUMPABLE,
SIOCGIFCONF, SIOCETHTOOL, SOCK_DGRAM, SOCK_RAW, SOCK_TYPE_MASK, TIOCLINUX, TIOCSTI,
SIOCGIFCONF, SIOCETHTOOL, SOCK_RAW, SOCK_TYPE_MASK, TIOCLINUX, TIOCSTI,
};

// ============================================================
Expand Down
35 changes: 29 additions & 6 deletions crates/sandlock-core/src/context/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,13 +332,36 @@ fn test_arg_filters_raw_sockets() {

#[test]
fn test_arg_filters_udp_denied_by_default() {
use crate::sys::structs::{BPF_JEQ, BPF_JMP, BPF_K};
// UDP is denied by default: no `udp://...` rule in net_allow.
// SOCK_DGRAM creation is denied when no net rule exists: nothing traps
// sends, so socket() is the only enforcement point.
let policy = Sandbox::builder().build().unwrap();
let filters = arg_filters(&policy);
// Should have JEQ SOCK_DGRAM
assert!(filters.iter().any(|f| f.code == (BPF_JMP | BPF_JEQ | BPF_K)
&& f.k == SOCK_DGRAM));
assert_eq!(count_jeq_2(&arg_filters(&policy)), 2);
}

/// AF_INET and SOCK_DGRAM share the constant 2, so presence of the
/// SOCK_DGRAM JEQ is observed as the JEQ-with-k==2 count: the AF_INET
/// domain check contributes one in every socket filter, the type check
/// contributes the second only when SOCK_DGRAM is in the blocked set.
fn count_jeq_2(filters: &[crate::sys::structs::SockFilter]) -> usize {
use crate::sys::structs::{BPF_JEQ, BPF_JMP, BPF_K};
filters.iter()
.filter(|f| f.code == (BPF_JMP | BPF_JEQ | BPF_K) && f.k == 2)
.count()
}

#[test]
fn test_arg_filters_udp_creatable_with_tcp_only_rules() {
// Any net rule moves UDP enforcement to send time: the on-behalf
// handlers deny destinations for rule-less protocols, so socket()
// must succeed (glibc getaddrinfo probes create UDP sockets).
let policy = Sandbox::builder().net_allow("1.1.1.1:443").build().unwrap();
assert_eq!(count_jeq_2(&arg_filters(&policy)), 1);
}

#[test]
fn test_arg_filters_udp_creatable_with_net_deny() {
let policy = Sandbox::builder().net_deny("10.0.0.0/8").build().unwrap();
assert_eq!(count_jeq_2(&arg_filters(&policy)), 1);
}

#[test]
Expand Down
18 changes: 18 additions & 0 deletions crates/sandlock-core/src/port_remap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,24 @@ pub(crate) async fn handle_bind(
Err(e) => return NotifAction::Errno(e.raw_os_error().unwrap_or(libc::EBADF)),
};

// A UDP bind is an inbound grant that Landlock cannot express
// (BIND_TCP is TCP-only). With no UDP rule the protocol is
// send-time deny-all, and a bound socket would still be a
// receive-only channel — so the bind is refused outright. Checked
// before the port extraction so ephemeral (port 0) binds are
// covered too.
let udp_deny_all = {
let ns = network.lock().await;
ns.effective_network_policy(notif.pid, crate::network::Protocol::Udp, None)
.denies_everything()
};
if udp_deny_all
&& crate::network::query_socket_protocol(dup_fd.as_raw_fd())
== Some(crate::network::Protocol::Udp)
{
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.
Expand Down
9 changes: 3 additions & 6 deletions crates/sandlock-core/src/resolved.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::sandbox::{Protocol, Sandbox};
use crate::sandbox::Sandbox;

/// Internal normalized view of a sandbox configuration.
///
Expand Down Expand Up @@ -46,7 +46,7 @@ pub(crate) struct SandboxFeatures {
pub(crate) http_acl: bool,
pub(crate) argv_safety_required: bool,
pub(crate) sysv_ipc_allowed: bool,
pub(crate) udp_or_icmp_allowed: bool,
pub(crate) net_allow_present: bool,
pub(crate) net_deny: bool,
}

Expand Down Expand Up @@ -84,10 +84,7 @@ impl SandboxFeatures {
http_acl,
argv_safety_required: sandbox.policy_fn.is_some() || exec_handler,
sysv_ipc_allowed: sandbox.allows_sysv_ipc(),
udp_or_icmp_allowed: sandbox
.net_allow
.iter()
.any(|r| matches!(r.protocol, Protocol::Udp | Protocol::Icmp)),
net_allow_present: !sandbox.net_allow.is_empty(),
net_deny: !sandbox.net_deny.is_empty(),
}
}
Expand Down
15 changes: 9 additions & 6 deletions crates/sandlock-core/src/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,12 +397,15 @@ pub struct Sandbox {
/// concrete host or "any IP." TCP and UDP rules carry ports; ICMP
/// rules have none.
///
/// **Protocol gating falls out of rule presence.** Sandlock denies
/// UDP and ICMP socket creation by default; opting in is "list at
/// least one rule for that protocol". Scheme-less specs expand to a
/// TCP + UDP rule pair at parse time, so any of them opts UDP in;
/// ICMP always needs an explicit rule (`icmp://*` for any ICMP
/// echo). TCP is always permitted.
/// **Protocol gating falls out of rule presence.** With no network
/// rules at all, Sandlock denies UDP and ICMP socket creation. Once
/// any network destination policy is active, datagram sockets may be
/// created so libc DNS/address-selection probes can run, but actual
/// UDP/ICMP destinations are still denied unless a matching rule for
/// that protocol exists. Scheme-less specs expand to a TCP + UDP rule
/// pair at parse time, so any of them opts UDP traffic in; ICMP always
/// needs an explicit rule (`icmp://*` for any ICMP echo). TCP is
/// always permitted.
///
/// Empty `net_allow` and empty `http_allow`/`http_deny` together
/// mean "deny all outbound" (Landlock direct path denies, no
Expand Down
12 changes: 12 additions & 0 deletions crates/sandlock-core/src/seccomp/notif.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,18 @@ pub enum NetworkPolicy {
}

impl NetworkPolicy {
/// True iff no destination can ever match: the allowlist for a protocol
/// nothing was granted to. Distinguishes "deny all" from `Unrestricted`
/// and from a `DenyList` (both default-allow).
pub fn denies_everything(&self) -> bool {
match self {
NetworkPolicy::AllowList { per_ip, cidrs, any_ip_ports } => {
per_ip.is_empty() && cidrs.is_empty() && any_ip_ports.is_empty()
}
_ => false,
}
}

/// True iff a connection to (ip, port) should be permitted.
pub fn allows(&self, ip: IpAddr, port: u16) -> bool {
// `::ffff:a.b.c.d` is the same destination as `a.b.c.d` (a
Expand Down
22 changes: 13 additions & 9 deletions crates/sandlock-core/src/seccomp_plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -570,17 +570,21 @@ pub(crate) fn arg_filters_resolved(resolved: &ResolvedSandbox) -> Vec<SockFilter
// header). Workloads that need ping should use the kernel ping
// socket (SOCK_DGRAM + IPPROTO_ICMP) via an `icmp://...` rule.
//
// SOCK_DGRAM is denied unless a UDP or ICMP rule exists in
// net_allow. The kernel ping socket uses SOCK_DGRAM with
// IPPROTO_ICMP, so the same type bit gates both; destination
// filtering at sendto (Phase 2) is what separates them per-rule.
// `--net-deny` is default-allow, so UDP and the kernel ping socket
// (both SOCK_DGRAM) must be creatable; without this the sandbox
// could not even do DNS over UDP. Per-destination UDP/ICMP denial
// is still enforced on the sendto on-behalf path via the DenyList.
// SOCK_DGRAM is denied only when no net rule exists at all. Once any
// `--net-allow`/`--net-deny` rule is present, connect/sendto/sendmsg/
// sendmmsg are trapped and destination-checked per protocol, and a
// protocol with no rule resolves to an empty allowlist that denies
// every destination — so creation itself is harmless and must be
// permitted: glibc's getaddrinfo creates UDP sockets for its RFC 3484
// address-sorting probes (connect, never send), and blocking those
// breaks name resolution for TCP-only rule sets. Gating stays at
// socket() only for the no-rules sandbox, where nothing traps sends.
// This must NOT widen to HTTP-ACL-only or policy_fn-only configs:
// their empty net_allow resolves the UDP policy to Unrestricted, so
// creation would mean unrestricted UDP egress.
let mut blocked_types: Vec<u32> = Vec::new();
blocked_types.push(SOCK_RAW);
if !features.udp_or_icmp_allowed && !features.net_deny {
if !features.net_allow_present && !features.net_deny {
blocked_types.push(SOCK_DGRAM);
}

Expand Down
Loading
Loading