Block WSL interop escapes from restricted filesystem sandboxes (#44286)

## Why

With network access enabled, WSL interop can launch Windows processes outside the Linux filesystem sandbox, including using `wsl.exe` to reenter the distribution as root.

## What changed

- Mask WSL interop sockets in bubblewrap when filesystem write access is restricted, and hide inherited procfs when running without a fresh proc mount.
- Deny `AF_VSOCK` sockets and `io_uring` under restricted filesystem policies even when ordinary network access is allowed, closing the VM socket route to host services.
- Reject legacy Landlock configurations that combine restricted filesystem access with unrestricted networking when the WSL interop directory exists.

## Testing

Add unit coverage for mount masking and legacy policy rejection, plus WSL integration tests for Windows process launch, bind aliases, direct `/init` invocation, root reentry, and `--no-proc` isolation. WSL tests skip when host prerequisites are unavailable.

GitOrigin-RevId: 1e8b97ac43675807ee541ca614534d9b1febf410
This commit is contained in:
Ian MacLeod
2026-09-09 19:43:41 +00:00
committed by copyberry
parent 5d3f8752fc
commit f71543813f
5 changed files with 496 additions and 5 deletions
+17
View File
@@ -55,6 +55,7 @@ const LINUX_PLATFORM_DEFAULT_READ_ROOTS: &[&str] = &[
];
const MAX_UNREADABLE_GLOB_MATCHES: usize = 8192;
pub(crate) const WSL_INTEROP_DIR: &str = "/run/WSL";
/// Options that control how bubblewrap is invoked.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -66,6 +67,8 @@ pub(crate) struct BwrapOptions {
pub mount_proc: bool,
/// How networking should be configured inside the bubblewrap sandbox.
pub network_mode: BwrapNetworkMode,
/// Hide the WSL Windows interop socket from commands with restricted filesystem access.
pub mask_wsl_interop: bool,
/// Optional maximum depth for expanding unreadable glob patterns with ripgrep.
///
/// Keep this uncapped by default so existing nested deny-read matches are
@@ -78,6 +81,7 @@ impl Default for BwrapOptions {
Self {
mount_proc: true,
network_mode: BwrapNetworkMode::FullAccess,
mask_wsl_interop: false,
glob_scan_max_depth: None,
}
}
@@ -329,6 +333,19 @@ fn create_bwrap_flags(
args.push("--new-session".to_string());
args.push("--die-with-parent".to_string());
args.extend(filesystem_args);
if options.mask_wsl_interop {
// WSL's Windows-process bridge uses Unix sockets under /run/WSL.
// Mask them after all filesystem binds so network access cannot bypass
// the Linux filesystem policy by launching an unrestricted Windows process.
args.push("--tmpfs".to_string());
args.push(WSL_INTEROP_DIR.to_string());
if !options.mount_proc {
// Without a fresh procfs, the root bind retains the host procfs.
// Hide it so /proc/<host-pid>/root cannot alias the interop sockets.
args.push("--tmpfs".to_string());
args.push("/proc".to_string());
}
}
// Request a user namespace explicitly rather than relying on bubblewrap's
// auto-enable behavior, which is skipped when the caller runs as uid 0.
args.push("--unshare-user".to_string());
+28 -5
View File
@@ -36,7 +36,7 @@ use seccompiler::apply_filter;
///
/// This function is responsible for:
/// - enabling `PR_SET_NO_NEW_PRIVS` when restrictions apply, and
/// - installing the network seccomp filter when network access is disabled.
/// - installing seccomp restrictions for network isolation and VM sockets.
///
/// Filesystem restrictions are intentionally handled by bubblewrap.
pub(crate) fn apply_permission_profile_to_current_thread(
@@ -52,7 +52,15 @@ pub(crate) fn apply_permission_profile_to_current_thread(
network_sandbox_policy,
allow_network_for_proxy,
proxy_routed_network,
);
)
.or_else(|| {
// VM sockets can reach host services outside the filesystem sandbox.
// In WSL2 they also allow Windows process launch through an alias of
// the interop socket, even when /run/WSL is masked. Keep ordinary
// network access while denying that host bridge.
(!file_system_sandbox_policy.has_full_disk_write_access())
.then_some(NetworkSeccompMode::VmSocketRestricted)
});
// `PR_SET_NO_NEW_PRIVS` is required for seccomp, but it also prevents
// setuid privilege elevation. Many `bwrap` deployments rely on setuid, so
@@ -91,6 +99,7 @@ pub(crate) fn apply_permission_profile_to_current_thread(
enum NetworkSeccompMode {
Restricted,
ProxyRouted,
VmSocketRestricted,
}
fn should_install_network_seccomp(
@@ -176,9 +185,13 @@ fn install_network_seccomp_filter_on_current_thread(
// Build rule map.
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
deny_syscall(&mut rules, libc::SYS_ptrace);
deny_syscall(&mut rules, libc::SYS_process_vm_readv);
deny_syscall(&mut rules, libc::SYS_process_vm_writev);
if mode != NetworkSeccompMode::VmSocketRestricted {
deny_syscall(&mut rules, libc::SYS_ptrace);
deny_syscall(&mut rules, libc::SYS_process_vm_readv);
deny_syscall(&mut rules, libc::SYS_process_vm_writev);
}
// io_uring can create AF_VSOCK sockets without a socket() syscall, so
// keep it unavailable in every mode with socket-family restrictions.
deny_syscall(&mut rules, libc::SYS_io_uring_setup);
deny_syscall(&mut rules, libc::SYS_io_uring_enter);
deny_syscall(&mut rules, libc::SYS_io_uring_register);
@@ -245,6 +258,16 @@ fn install_network_seccomp_filter_on_current_thread(
rules.insert(libc::SYS_socket, vec![deny_non_ip_socket]);
rules.insert(libc::SYS_socketpair, vec![deny_non_unix_socketpair]);
}
NetworkSeccompMode::VmSocketRestricted => {
let deny_vsock = SeccompRule::new(vec![SeccompCondition::new(
0,
SeccompCmpArgLen::Dword,
SeccompCmpOp::Eq,
libc::AF_VSOCK as u64,
)?])?;
rules.insert(libc::SYS_socket, vec![deny_vsock.clone()]);
rules.insert(libc::SYS_socketpair, vec![deny_vsock]);
}
}
let filter = SeccompFilter::new(
@@ -22,6 +22,7 @@ use std::time::Duration;
use crate::bwrap::BwrapNetworkMode;
use crate::bwrap::BwrapOptions;
use crate::bwrap::WSL_INTEROP_DIR;
use crate::bwrap::create_bwrap_command_args;
use crate::landlock::apply_permission_profile_to_current_thread;
use crate::launcher::exec_bwrap;
@@ -186,7 +187,9 @@ pub fn run_main() -> ! {
use_legacy_landlock,
&file_system_sandbox_policy,
network_sandbox_policy,
allow_network_for_proxy,
&sandbox_policy_cwd,
Path::new(WSL_INTEROP_DIR),
);
// Inner stage: apply seccomp/no_new_privs after bubblewrap has already
@@ -378,7 +381,9 @@ fn ensure_legacy_landlock_mode_supports_policy(
use_legacy_landlock: bool,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
allow_network_for_proxy: bool,
sandbox_policy_cwd: &Path,
wsl_interop_dir: &Path,
) {
if use_legacy_landlock
&& file_system_sandbox_policy
@@ -388,6 +393,18 @@ fn ensure_legacy_landlock_mode_supports_policy(
"permission profiles requiring direct runtime enforcement are incompatible with --use-legacy-landlock"
);
}
if use_legacy_landlock
&& network_sandbox_policy.is_enabled()
&& !allow_network_for_proxy
&& !file_system_sandbox_policy.has_full_disk_write_access()
// Interop can use another binfmt handler or a newly created socket.
// An active endpoint probe would race with sandboxed command startup.
&& wsl_interop_dir.is_dir()
{
panic!(
"legacy Landlock cannot isolate WSL Windows interop with network access enabled; use bubblewrap or restrict network access"
);
}
}
fn run_bwrap_with_proc_fallback(
@@ -414,6 +431,8 @@ fn run_bwrap_with_proc_fallback(
let options = BwrapOptions {
mount_proc,
network_mode,
mask_wsl_interop: !file_system_sandbox_policy.has_full_disk_write_access()
&& Path::new(WSL_INTEROP_DIR).is_dir(),
..Default::default()
};
let mut bwrap_args = build_bwrap_argv(
@@ -203,6 +203,48 @@ fn inserts_unshare_net_when_proxy_only_network_mode_requested() {
assert!(argv.contains(&"--unshare-net".to_string()));
}
#[test]
fn masks_wsl_interop_with_full_network_and_restricted_filesystem() {
let argv = build_bwrap_argv(
vec!["/bin/true".to_string()],
&read_only_file_system_policy(),
Path::new("/"),
Path::new("/"),
BwrapOptions {
network_mode: BwrapNetworkMode::FullAccess,
mask_wsl_interop: true,
..Default::default()
},
)
.expect("build bwrap argv")
.args;
assert!(argv.windows(2).any(|args| args == ["--tmpfs", "/run/WSL"]));
assert!(!argv.iter().any(|arg| arg == "--unshare-net"));
}
#[test]
fn masks_inherited_procfs_when_wsl_interop_is_masked_without_fresh_proc() {
let argv = build_bwrap_argv(
vec!["/bin/true".to_string()],
&read_only_file_system_policy(),
Path::new("/"),
Path::new("/"),
BwrapOptions {
mount_proc: false,
network_mode: BwrapNetworkMode::FullAccess,
mask_wsl_interop: true,
..Default::default()
},
)
.expect("build bwrap argv")
.args;
assert!(argv.windows(2).any(|args| args == ["--tmpfs", "/run/WSL"]));
assert!(argv.windows(2).any(|args| args == ["--tmpfs", "/proc"]));
assert!(!argv.windows(2).any(|args| args == ["--proc", "/proc"]));
}
#[test]
fn proxy_only_mode_takes_precedence_over_full_network_policy() {
let mode = bwrap_network_mode(
@@ -673,13 +715,44 @@ fn legacy_landlock_rejects_split_only_filesystem_policies() {
/*use_legacy_landlock*/ true,
&policy,
NetworkSandboxPolicy::Restricted,
/*allow_network_for_proxy*/ false,
temp_dir.path(),
&temp_dir.path().join("WSL"),
);
});
assert!(result.is_err());
}
#[test]
fn legacy_landlock_rejects_full_network_when_wsl_interop_is_available() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let wsl_interop_dir = temp_dir.path().join("WSL");
std::fs::create_dir(&wsl_interop_dir).expect("create interop directory");
let policy = read_only_file_system_policy();
let result = std::panic::catch_unwind(|| {
ensure_legacy_landlock_mode_supports_policy(
/*use_legacy_landlock*/ true,
&policy,
NetworkSandboxPolicy::Enabled,
/*allow_network_for_proxy*/ false,
temp_dir.path(),
&wsl_interop_dir,
);
});
assert!(result.is_err());
ensure_legacy_landlock_mode_supports_policy(
/*use_legacy_landlock*/ true,
&policy,
NetworkSandboxPolicy::Enabled,
/*allow_network_for_proxy*/ true,
temp_dir.path(),
&wsl_interop_dir,
);
}
#[test]
fn valid_inner_stage_modes_do_not_panic() {
ensure_inner_stage_mode_is_valid(