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
30 changes: 26 additions & 4 deletions crates/sandlock-core/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ use crate::sys::structs::{
BPF_ABS, BPF_ALU, BPF_AND, BPF_JEQ, BPF_JSET, BPF_JMP, BPF_K, BPF_LD, BPF_RET, BPF_W,
CLONE_NS_FLAGS, DEFAULT_DENY_SYSCALLS, EPERM,
SECCOMP_RET_ALLOW, SECCOMP_RET_ERRNO,
SIOCETHTOOL, SIOCGIFADDR, SIOCGIFBRDADDR, SIOCGIFCONF, SIOCGIFDSTADDR,
SIOCGIFFLAGS, SIOCGIFHWADDR, SIOCGIFINDEX, SIOCGIFNAME, SIOCGIFNETMASK,
SOCK_DGRAM, SOCK_RAW, SOCK_TYPE_MASK, TIOCLINUX, TIOCSTI,
PR_SET_DUMPABLE, PR_SET_SECUREBITS, PR_SET_PTRACER,
OFFSET_ARGS0_LO, OFFSET_ARGS1_LO, OFFSET_ARGS2_LO, OFFSET_ARGS3_LO, OFFSET_NR,
Expand Down Expand Up @@ -446,7 +448,7 @@ pub fn deny_syscall_numbers(policy: &Policy) -> Vec<u32> {
///
/// Returns a `Vec<SockFilter>` containing self-contained BPF blocks for:
/// - clone: block namespace creation flags
/// - ioctl: block TIOCSTI, TIOCLINUX
/// - ioctl: block TIOCSTI, TIOCLINUX, SIOCGIF*, SIOCETHTOOL
/// - prctl: block PR_SET_DUMPABLE, PR_SET_SECUREBITS, PR_SET_PTRACER
/// - socket: block SOCK_RAW/SOCK_DGRAM on AF_INET/AF_INET6 (with type mask)
pub fn arg_filters(policy: &Policy) -> Vec<SockFilter> {
Expand All @@ -471,9 +473,25 @@ pub fn arg_filters(policy: &Policy) -> Vec<SockFilter> {
insns.push(jump(BPF_JMP | BPF_JSET | BPF_K, CLONE_NS_FLAGS as u32, 0, 1));
insns.push(stmt(BPF_RET | BPF_K, ret_errno));

// --- ioctl: block dangerous commands (TIOCSTI, TIOCLINUX) ---
// --- ioctl: block dangerous commands ---
// Block terminal injection (TIOCSTI, TIOCLINUX) and network interface
// enumeration ioctls (SIOCGIF*, SIOCETHTOOL) to complement NETLINK_ROUTE
// virtualization.
// Layout: LD NR, JEQ ioctl (skip 1 + N*2), LD arg1, [JEQ cmd, RET ERRNO] * N
let dangerous_ioctls: &[u32] = &[TIOCSTI as u32, TIOCLINUX as u32];
let dangerous_ioctls: &[u32] = &[
TIOCSTI as u32,
TIOCLINUX as u32,
SIOCGIFNAME as u32,
SIOCGIFCONF as u32,
SIOCGIFFLAGS as u32,
SIOCGIFADDR as u32,
SIOCGIFDSTADDR as u32,
SIOCGIFBRDADDR as u32,
SIOCGIFNETMASK as u32,
SIOCGIFHWADDR as u32,
SIOCGIFINDEX as u32,
SIOCETHTOOL as u32,
];
let n_ioctls = dangerous_ioctls.len();
let skip_count = (1 + n_ioctls * 2) as u8;
insns.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFFSET_NR));
Expand Down Expand Up @@ -1089,11 +1107,15 @@ mod tests {
// Should contain JEQ for ioctl syscall nr
assert!(filters.iter().any(|f| f.code == (BPF_JMP | BPF_JEQ | BPF_K)
&& f.k == libc::SYS_ioctl as u32));
// Should contain JEQ for TIOCSTI and TIOCLINUX
// Should contain JEQ for TIOCSTI, TIOCLINUX, and SIOCGIF*/SIOCETHTOOL
assert!(filters.iter().any(|f| f.code == (BPF_JMP | BPF_JEQ | BPF_K)
&& f.k == TIOCSTI as u32));
assert!(filters.iter().any(|f| f.code == (BPF_JMP | BPF_JEQ | BPF_K)
&& f.k == TIOCLINUX as u32));
assert!(filters.iter().any(|f| f.code == (BPF_JMP | BPF_JEQ | BPF_K)
&& f.k == SIOCGIFCONF as u32));
assert!(filters.iter().any(|f| f.code == (BPF_JMP | BPF_JEQ | BPF_K)
&& f.k == SIOCETHTOOL as u32));
// Should contain JEQ for prctl syscall nr
assert!(filters.iter().any(|f| f.code == (BPF_JMP | BPF_JEQ | BPF_K)
&& f.k == libc::SYS_prctl as u32));
Expand Down
31 changes: 30 additions & 1 deletion crates/sandlock-core/src/procfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const SENSITIVE_PATHS: &[&str] = &[
"/proc/keys",
"/proc/key-users",
"/proc/sysrq-trigger",
"/sys/class/net",
"/sys/firmware",
"/sys/kernel/security",
];
Expand Down Expand Up @@ -251,6 +252,25 @@ pub(crate) fn generate_proc_mountinfo(
buf.into_bytes()
}

// ============================================================
// /proc/net/dev and /proc/net/if_inet6 virtualization
// ============================================================

/// Generate a synthetic /proc/net/dev showing only the loopback interface.
pub(crate) fn generate_proc_net_dev() -> Vec<u8> {
concat!(
"Inter-| Receive | Transmit\n",
" face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed\n",
" lo: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n",
).as_bytes().to_vec()
}

/// Generate a synthetic /proc/net/if_inet6 showing only loopback (::1).
pub(crate) fn generate_proc_net_if_inet6() -> Vec<u8> {
// Format: address ifindex prefix_len scope flags ifname
b"00000000000000000000000000000001 01 80 10 80 lo\n".to_vec()
}

// ============================================================
// /proc/net/tcp filtering
// ============================================================
Expand Down Expand Up @@ -424,6 +444,14 @@ pub(crate) async fn handle_proc_open(
return inject_memfd(&content);
}

// Virtualize /proc/net/dev and /proc/net/if_inet6 — show loopback only.
if path == "/proc/net/dev" {
return inject_memfd(&generate_proc_net_dev());
}
if path == "/proc/net/if_inet6" {
return inject_memfd(&generate_proc_net_if_inet6());
}

// Virtualize /proc/net/tcp and /proc/net/tcp6 when port_remap is active.
if policy.port_remap && (path == "/proc/net/tcp" || path == "/proc/net/tcp6") {
let is_v6 = path.ends_with('6');
Expand Down Expand Up @@ -857,7 +885,8 @@ mod tests {
assert!(!is_sensitive_proc("/proc/cpuinfo"));
assert!(!is_sensitive_proc("/proc/meminfo"));
assert!(!is_sensitive_proc("/proc/1/status"));
assert!(!is_sensitive_proc("/sys/class/net"));
assert!(is_sensitive_proc("/sys/class/net"));
assert!(is_sensitive_proc("/sys/class/net/eth0"));
}

#[test]
Expand Down
12 changes: 12 additions & 0 deletions crates/sandlock-core/src/sys/structs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,18 @@ pub const CLONE_NS_FLAGS: u64 = CLONE_NEWNS
pub const TIOCSTI: u64 = 0x5412;
pub const TIOCLINUX: u64 = 0x541C;

// Network interface ioctls (linux/sockios.h)
pub const SIOCGIFNAME: u64 = 0x8910;
pub const SIOCGIFCONF: u64 = 0x8912;
pub const SIOCGIFFLAGS: u64 = 0x8913;
pub const SIOCGIFADDR: u64 = 0x8915;
pub const SIOCGIFDSTADDR: u64 = 0x8917;
pub const SIOCGIFBRDADDR: u64 = 0x8919;
pub const SIOCGIFNETMASK: u64 = 0x891B;
pub const SIOCGIFHWADDR: u64 = 0x8927;
pub const SIOCGIFINDEX: u64 = 0x8933;
pub const SIOCETHTOOL: u64 = 0x8946;

// ============================================================
// Dangerous prctl options
// ============================================================
Expand Down
134 changes: 134 additions & 0 deletions crates/sandlock-core/tests/integration/test_netlink_virt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,140 @@ async fn getaddrinfo_ai_addrconfig_returns_v4_and_v6() {
assert!(result.success());
}

/// /proc/net/dev should be virtualized to show only loopback.
#[tokio::test]
async fn proc_net_dev_shows_only_lo() {
let out = temp_out("proc-net-dev");
let script = format!(concat!(
"lines = open('/proc/net/dev').readlines()\n",
"ifaces = [l.split(':')[0].strip() for l in lines[2:]]\n",
"open('{out}', 'w').write(','.join(ifaces))\n",
), out = out.display());

let policy = base_policy().build().unwrap();
let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script])
.await.unwrap();

let contents = std::fs::read_to_string(&out).unwrap_or_default();
let _ = std::fs::remove_file(&out);
assert_eq!(contents.trim(), "lo", "expected only lo in /proc/net/dev, got: {}", contents);
assert!(result.success());
}

/// /proc/net/if_inet6 should be virtualized to show only loopback.
#[tokio::test]
async fn proc_net_if_inet6_shows_only_lo() {
let out = temp_out("proc-net-if-inet6");
let script = format!(concat!(
"lines = open('/proc/net/if_inet6').readlines()\n",
"ifaces = [l.split()[-1] for l in lines if l.strip()]\n",
"open('{out}', 'w').write(','.join(ifaces))\n",
), out = out.display());

let policy = base_policy().build().unwrap();
let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script])
.await.unwrap();

let contents = std::fs::read_to_string(&out).unwrap_or_default();
let _ = std::fs::remove_file(&out);
assert_eq!(contents.trim(), "lo", "expected only lo in /proc/net/if_inet6, got: {}", contents);
assert!(result.success());
}

/// SIOCGIFCONF ioctl should be blocked by the BPF arg filter, returning EPERM.
#[tokio::test]
async fn ioctl_siocgifconf_blocked() {
let out = temp_out("ioctl-siocgifconf");
let script = format!(concat!(
"import fcntl, struct, socket, errno\n",
"SIOCGIFCONF = 0x8912\n",
"s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
"buf = b'\\x00' * 4096\n",
"ifc = struct.pack('iP', len(buf), 0)\n",
"try:\n",
" fcntl.ioctl(s.fileno(), SIOCGIFCONF, ifc)\n",
" result = 'ALLOWED'\n",
"except OSError as e:\n",
" result = f'BLOCKED:{{e.errno}}'\n",
"finally:\n",
" s.close()\n",
"open('{out}', 'w').write(result)\n",
), out = out.display());

let policy = base_policy().build().unwrap();
let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script])
.await.unwrap();

let contents = std::fs::read_to_string(&out).unwrap_or_default();
let _ = std::fs::remove_file(&out);
assert_eq!(
contents.trim(),
&format!("BLOCKED:{}", libc::EPERM),
"SIOCGIFCONF should be blocked with EPERM, got: {}", contents
);
assert!(result.success());
}

/// SIOCETHTOOL ioctl should be blocked by the BPF arg filter.
#[tokio::test]
async fn ioctl_siocethtool_blocked() {
let out = temp_out("ioctl-siocethtool");
let script = format!(concat!(
"import fcntl, struct, socket\n",
"SIOCETHTOOL = 0x8946\n",
"s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
"ifr = struct.pack('16sP', b'eth0', 0)\n",
"try:\n",
" fcntl.ioctl(s.fileno(), SIOCETHTOOL, ifr)\n",
" result = 'ALLOWED'\n",
"except OSError as e:\n",
" result = f'BLOCKED:{{e.errno}}'\n",
"finally:\n",
" s.close()\n",
"open('{out}', 'w').write(result)\n",
), out = out.display());

let policy = base_policy().build().unwrap();
let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script])
.await.unwrap();

let contents = std::fs::read_to_string(&out).unwrap_or_default();
let _ = std::fs::remove_file(&out);
assert_eq!(
contents.trim(),
&format!("BLOCKED:{}", libc::EPERM),
"SIOCETHTOOL should be blocked with EPERM, got: {}", contents
);
assert!(result.success());
}

/// /sys/class/net should be blocked as a sensitive path.
#[tokio::test]
async fn sys_class_net_blocked() {
let out = temp_out("sys-class-net");
let script = format!(concat!(
"import os\n",
"try:\n",
" entries = os.listdir('/sys/class/net')\n",
" result = 'ALLOWED:' + ','.join(entries)\n",
"except OSError as e:\n",
" result = f'BLOCKED:{{e.errno}}'\n",
"open('{out}', 'w').write(result)\n",
), out = out.display());

let policy = base_policy().build().unwrap();
let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script])
.await.unwrap();

let contents = std::fs::read_to_string(&out).unwrap_or_default();
let _ = std::fs::remove_file(&out);
assert!(
contents.starts_with("BLOCKED:"),
"/sys/class/net should be blocked, got: {}", contents
);
assert!(result.success());
}

#[tokio::test]
async fn non_route_netlink_still_blocked() {
let out = temp_out("netlink-audit-blocked");
Expand Down
Loading