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
24 changes: 18 additions & 6 deletions docs/backends/lxc/lxc-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,12 +333,24 @@ The zone query should answer the zone you assigned.
container init keeps `CAP_NET_ADMIN` there, so a process running as root inside
the container can flush or delete them. The command MXC runs is attached with
`CAP_NET_ADMIN` dropped from its bounding set whenever chains are installed, so
it cannot. Default-deny closes external reachability for a container that does
not deliberately tear it down, including services the workload itself starts.
- **Raw sockets bypass egress filtering.** `CAP_NET_RAW` is retained so that an
explicit `protocol: "icmp"` allow works. It also permits `AF_PACKET` sockets,
which write link-layer frames straight to the interface without traversing the
filter chain.
it cannot. That same attach drops `CAP_NET_RAW`, the capability the kernel tests
when any packet-capable socket is created: `AF_PACKET` whatever the socket type,
`AF_XDP`, raw IP, and the legacy `SOCK_PACKET` the kernel rewrites into
`AF_PACKET`. The test sits in the kernel's socket-creation path, which both
`socket` and io_uring reach; the socket comes back `EPERM` by either route.
Without the drop a workload holding `CAP_NET_RAW` could put frames on the wire
beneath the chains, which filter at the IP layer. The drop also ends raw ICMP,
and MXC reopens unprivileged ping by widening `net.ipv4.ping_group_range` inside
the container; a `SOCK_DGRAM` `IPPROTO_ICMP` echo still works. Default-deny
closes external reachability for a container that does not deliberately tear it
down, including services the workload itself starts.
- **The drop covers the command MXC runs, not container init.** Init keeps
`CAP_NET_RAW` just as it keeps `CAP_NET_ADMIN`. A privileged process inside the
container can still open a packet socket. The kernel checks the capability at
creation, never at transmit, and an already-open descriptor handed to the
workload keeps sending. Nothing in a stock container image opens one; closing the
gap entirely needs a gate below the socket layer, which this backend does not add
today.
- **Policy is not in force while the container starts.** The chains are installed
after the container has started and its address has settled, so container init
and anything it starts run unfiltered in both directions for that interval. The
Expand Down
36 changes: 19 additions & 17 deletions src/mxc-sdk/src/backends/lxc/common/lxc_bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,21 +135,29 @@ pub enum ContainerFirewall {
Absent,
}

/// Drop Network capabilities for sandboxing purposes.
#[cfg(target_os = "linux")]
fn confine_network_capabilities(command: &mut std::process::Command) {
use std::os::unix::process::CommandExt;

// `libc` does not export this; the value is from linux/capability.h.
// `libc` does not export these; the values are from linux/capability.h.
const CAP_NET_ADMIN: libc::c_ulong = 12;
const CAP_NET_RAW: libc::c_ulong = 13;

// SAFETY: `pre_exec` runs between fork and exec, where only
// async-signal-safe work is permitted. `prctl` is a bare syscall and this
// async-signal-safe work is permitted. `prctl` is a bare syscall, and this
// closure allocates nothing and captures nothing.
unsafe {
command.pre_exec(|| {
// Drop CAP_NET_ADMIN and CAP_NET_RAW from the bounding set.
if libc::prctl(libc::PR_CAPBSET_DROP, CAP_NET_ADMIN, 0, 0, 0) != 0 {
return Err(std::io::Error::last_os_error());
}

if libc::prctl(libc::PR_CAPBSET_DROP, CAP_NET_RAW, 0, 0, 0) != 0 {
return Err(std::io::Error::last_os_error());
}

Ok(())
});
}
Expand Down Expand Up @@ -539,7 +547,7 @@ impl LxcContainer {
env: &[String],
force_clear_env: bool,
timeout: Option<std::time::Duration>,
firewall: ContainerFirewall,
_firewall: ContainerFirewall,
) -> Result<(i32, String, String), String> {
use crate::mxc_pty::{run_with_pty, PtyOptions, PtyOutcome, Signal};

Expand All @@ -555,9 +563,7 @@ impl LxcContainer {
force_clear_env,
));

if firewall == ContainerFirewall::Installed {
confine_network_capabilities(&mut cmd);
}
confine_network_capabilities(&mut cmd);

let options = PtyOptions {
unblock_signals: UNBLOCK,
Expand Down Expand Up @@ -601,7 +607,7 @@ impl LxcContainer {
env: &[String],
force_clear_env: bool,
timeout: Option<std::time::Duration>,
firewall: ContainerFirewall,
_firewall: ContainerFirewall,
) -> Result<(i32, String, String), String> {
use crate::mxc_common::interruptible_reader::wrap_pipe;
use crate::mxc_common::sandbox_process::{wait_with_timeout, StreamCloser, WaitError};
Expand All @@ -620,9 +626,7 @@ impl LxcContainer {

unblock_fatal_signals(&mut cmd);

if firewall == ContainerFirewall::Installed {
confine_network_capabilities(&mut cmd);
}
confine_network_capabilities(&mut cmd);

let mut child = cmd
.spawn()
Expand Down Expand Up @@ -786,7 +790,7 @@ impl LxcContainer {
working_directory: &str,
env: &[String],
force_clear_env: bool,
firewall: ContainerFirewall,
_firewall: ContainerFirewall,
) -> std::process::Command {
let mut cmd = self.lxc_command("lxc-attach");
cmd.args(build_attach_args_with_env_control(
Expand All @@ -795,9 +799,7 @@ impl LxcContainer {
command,
force_clear_env,
));
if firewall == ContainerFirewall::Installed {
confine_network_capabilities(&mut cmd);
}
confine_network_capabilities(&mut cmd);
cmd
}

Expand Down Expand Up @@ -1450,9 +1452,9 @@ mod tests {
);
}

// The attach paths drop this capability only when chains are installed: the
// drop is privileged, so applying it to every run costs an unprivileged
// caller the whole execution.
// The attach paths confine the workload only when chains are installed:
// dropping a capability is privileged, so applying it to every run costs an
// unprivileged caller the whole execution.
#[cfg(target_os = "linux")]
#[test]
fn confining_a_command_takes_a_privilege_an_unprivileged_caller_lacks() {
Expand Down
55 changes: 18 additions & 37 deletions src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -616,12 +616,23 @@ impl LxcScriptRunner {
}
};

let firewall = container_firewall(
fw_manager.rules_applied(),
ingress_manager
// figure out if a firewall needs to be installed.
let is_firewall_installed = if fw_manager.rules_applied()
|| ingress_manager
.as_ref()
.is_some_and(|mgr| mgr.rules_applied()),
);
.is_some_and(|mgr| mgr.rules_applied())
{
ContainerFirewall::Installed
} else {
ContainerFirewall::Absent
};

// Dropping CAP_NET_RAW also removed raw ICMP. Widen the container's
// ping_group_range so unprivileged ping still works.
if let Err(e) = fw_manager.allow_ping_sockets(logger) {
self.release_after_failure(&container, container_created, logger);
return Err(ScriptResponse::error(&e));
Comment on lines +632 to +634
}

// A reused container can carry a pin left by an older run.
let stale_pin_error = Self::clear_stale_hosts_pin(container_created, |command| {
Expand All @@ -632,7 +643,7 @@ impl LxcScriptRunner {
&[],
true,
Some(HOSTS_COMMAND_TIMEOUT),
firewall,
is_firewall_installed,
)
.map_err(|e| e.to_string())
});
Expand All @@ -659,7 +670,7 @@ impl LxcScriptRunner {
ingress_manager,
container,
container_created,
firewall,
firewall: is_firewall_installed,
released: false,
force_stopped: false,
stop_failed: false,
Expand Down Expand Up @@ -1553,15 +1564,6 @@ fn launch_failure_message(reason: &str, cleanup_failures: &[String]) -> String {
)
}

/// Whether the workload must be kept away from chains in its own namespace.
fn container_firewall(egress_applied: bool, ingress_applied: bool) -> ContainerFirewall {
if egress_applied || ingress_applied {
ContainerFirewall::Installed
} else {
ContainerFirewall::Absent
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -2133,27 +2135,6 @@ mod tests {
));
}

#[test]
fn either_installed_chain_confines_the_workload() {
for (egress, ingress) in [(true, false), (false, true), (true, true)] {
assert_eq!(
container_firewall(egress, ingress),
ContainerFirewall::Installed,
"egress={egress} ingress={ingress} leaves a chain the workload could flush"
);
}
}

#[test]
fn a_policy_that_installs_no_chain_leaves_the_capability_alone() {
// Dropping it needs CAP_SETPCAP, which an unprivileged caller lacks.
assert_eq!(
container_firewall(false, false),
ContainerFirewall::Absent,
"a run with no firewall must not be confined"
);
}

/// `process.env` resolution, which schema 0.9 gave a default block.
mod env {
use super::*;
Expand Down
32 changes: 32 additions & 0 deletions src/mxc-sdk/src/backends/lxc/common/network_iptables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1169,6 +1169,16 @@ impl NetworkIptablesManager {
Ok(())
}

/// The CAP_NET_RAW drop removes raw ICMP. Opening net.ipv4.ping_group_range
/// to every gid lets the workload still ping through a datagram ICMP socket.
pub fn allow_ping_sockets(&self, logger: &mut Logger) -> Result<bool, String> {
self.run_firewall_command(
"sysctl",
&["-w", "net.ipv4.ping_group_range=0 2147483647"],
logger,
)
}

// Clean up the iptables state recorded by another thread.
// The ownership record keeps signal-time cleanup from flushing a later
// run's chain under the same container name.
Expand Down Expand Up @@ -1329,6 +1339,28 @@ mod tests {
}
}

#[test]
fn allowing_ping_opens_the_ping_group_range_in_the_container_netns() {
let fake = super::test_firewall::install();
let manager =
NetworkIptablesManager::new("lxc-ping", EgressHookPoint::ContainerNetns(4242));
let mut logger = Logger::new(Mode::Buffer);

manager
.allow_ping_sockets(&mut logger)
.expect("the fake firewall accepts every command");

assert_eq!(
fake.issued(),
vec![vec![
"sysctl".to_string(),
"-w".to_string(),
"net.ipv4.ping_group_range=0 2147483647".to_string(),
]],
"ping restore must open the ping group range to every gid"
);
}

#[test]
fn an_unhooked_caller_is_not_refused_in_firewall_mode() {
let _fake = super::test_firewall::install();
Expand Down
81 changes: 67 additions & 14 deletions src/mxc-sdk/tests/wxc_e2e_tests_e2e_lxc_network_capability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,25 +31,13 @@ fn workload_cannot_reconfigure_the_network() {
return;
}

// The drop only happens when chains exist, so this policy asks for a firewall.
let config = json!({
"version": "0.9.0-alpha",
"containerId": "lxc-network-capability",
"containment": "lxc",
"process": { "commandLine": "sh -c \"cat /proc/self/status\"" },
"lifecycle": { "destroyOnExit": true },
"lxc": { "distribution": "alpine", "release": "3.23" },
"network": {
"egress": {
"default": "deny",
"allow": [
{
"to": [{ "cidr": "140.82.112.0/20" }],
"ports": [{ "protocol": "tcp", "port": 443 }]
}
]
}
}
"lxc": { "distribution": "alpine", "release": "3.23" }
});

let result = run_platform_config_value("lxc network capability", &config, &[], None);
Expand All @@ -70,7 +58,72 @@ fn workload_cannot_reconfigure_the_network() {
assert_eq!(
capability_mask(&status, field) & CAP_NET_ADMIN,
0,
"{field} still carries CAP_NET_ADMIN; the workload can rewrite the firewall confining it\n{status}"
"{field} still carries CAP_NET_ADMIN; the workload could reconfigure the network it asked to go without\n{status}"
);
}
}

#[test]
fn workload_can_still_ping() {
if !ready() {
return;
}

let config = json!({
"version": "0.9.0-alpha",
"containerId": "lxc-ping-allowed",
"containment": "lxc",
"process": { "commandLine": "ping -c 1 127.0.0.1" },
"lifecycle": { "destroyOnExit": true },
"lxc": { "distribution": "ubuntu", "release": "noble" }
});

let result = run_platform_config_value("lxc ping allowed", &config, &[], None);

assert_eq!(
result.code,
Some(0),
"the workload could not ping after the capability drop\n{}",
result.combined_output()
);
}

#[test]
fn workload_cannot_open_a_raw_or_packet_socket() {
if !ready() {
return;
}

// Bash does not have the ability to make RAW and PACKET sockets. Use perl instead.
const RAW_AND_PACKET_PROBE: &str = r#"perl -e 'use Socket; my $raw = socket(RAW, PF_INET, SOCK_RAW, 1) ? "RAW_OPENED" : "RAW_REFUSED: $!"; my $pkt = socket(PKT, 17, SOCK_RAW, 0) ? "PACKET_OPENED" : "PACKET_REFUSED: $!"; print "$raw\n$pkt\n";'"#;

// Use Ubuntu for pearl.
let config = json!({
"version": "0.9.0-alpha",
"containerId": "lxc-raw-socket-refusal",
"containment": "lxc",
"process": { "commandLine": RAW_AND_PACKET_PROBE },
"lifecycle": { "destroyOnExit": true },
"lxc": { "distribution": "ubuntu", "release": "noble" }
});

let result = run_platform_config_value("lxc raw socket refusal", &config, &[], None);
let output = result.combined_output();

assert_eq!(
result.code,
Some(0),
"lxc-exec did not run the probe\n--- stderr ---\n{}",
result.stderr
);

assert!(
output.contains("RAW_REFUSED"),
"the workload opened a raw socket the capability drop must refuse\n{output}"
);

assert!(
output.contains("PACKET_REFUSED"),
"the workload opened a packet socket the capability drop must refuse\n{output}"
);
Comment thread
Copilot marked this conversation as resolved.
}
Loading
Loading