Repository navigation
Refuse AF_PACKET - #1457
Refuse AF_PACKET#1457Darren Hoehna (dhoehna) wants to merge 4 commits into
Conversation
CAP_NET_RAW stays, because an explicit protocol: "icmp" allow needs a raw socket, and it also opens AF_PACKET, which reaches the interface below the hook the chains hang on. A syscall entering under numbering the filter was not built for kills the process rather than passing unchecked. AB#64150389 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9df5d37d-b3a2-487c-95df-ded398109b1d
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
🟡 Changes recommended
The filter remains bypassable through io_uring, Windows Hyperlight builds fail, and the CLI refactor introduces diagnostic regressions.
4 open findings
What changed in this PR
Adds LXC network confinement intended to block AF_PACKET while retaining CAP_NET_RAW for ICMP, alongside an executor CLI refactor.
Changes:
- Adds a seccomp socket filter and capability assertions.
- Refactors LXC CLI argument and mode handling.
- Updates ICMP tests and LXC limitations documentation.
| File | Description |
|---|---|
tests/scripts/run_lxc_network_ga_egress_test.sh |
Removes the host ICMP preflight. |
tests/configs/lxc_network_ga_egress_icmp_allowed.json |
Exposes ping diagnostics. |
src/tools/lxc/src/main.rs |
Delegates CLI operations to the new argument module. |
src/tools/lxc/src/linux_executor_arguments.rs |
Encapsulates CLI parsing and operations. |
src/mxc-sdk/tests/wxc_e2e_tests_e2e_lxc_network_capability.rs |
Checks retained capabilities and seccomp mode. |
src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs |
Updates confinement test naming. |
src/mxc-sdk/src/backends/lxc/common/lxc_bindings.rs |
Adds the seccomp AF_PACKET filter. |
docs/backends/lxc/lxc-backend.md |
Removes the raw-socket limitation. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| libc::sock_filter { code: LOAD_WORD, jt: 0, jf: 0, k: SYSCALL_NUMBER }, | ||
| libc::sock_filter { code: JUMP_IF_AT_LEAST, jt: 5, jf: 0, k: USES_X32_ABI_FLAG }, // Test 32 bit | ||
| libc::sock_filter { code: JUMP_IF_EQUAL, jt: 0, jf: 2, k: SOCKET_SYSCALL }, // Only process a socket syscall | ||
| libc::sock_filter { code: LOAD_WORD, jt: 0, jf: 0, k: FIRST_ARGUMENT }, | ||
| libc::sock_filter { code: JUMP_IF_EQUAL, jt: 1, jf: 0, k: libc::AF_PACKET as u32 }, // return error if using AF_PACKET |
| assert_eq!( | ||
| status_number(&status, "Seccomp:"), | ||
| 2, | ||
| "the workload runs with no seccomp filter; AF_PACKET reaches the interface below the chains\n{status}" | ||
| ); |
| if let Err(e) = logger.enable_file_sink(std::path::Path::new(log_path)) { | ||
| eprintln!("Warning: could not open log file '{}': {}", log_path, e); | ||
| process::exit(1); | ||
| } |
| pub fn should_display_avalible_backends(&self) -> bool { | ||
| self.available_backends | ||
| } |
Gudge (MGudgin)
left a comment
There was a problem hiding this comment.
Review summary
Requesting changes on the current PR head c6a95211a. The Windows x64 Hyperlight feature build fails (inline), and the security-sensitive filter has no behavioral test of the socket it is meant to refuse. Please address both before merge. The backend's broader packet-socket guarantee also needs either complete enforcement or an explicit limitation.
Scope and clean checks: The eight-file, +430/-288 local diff exactly matches gh pr diff 1457. The PR is behind the current base (a0b66e63c3); the reviewed three-dot range uses merge-base 7cd00d1ee6. The existing CAP_NET_ADMIN bounding-set drop is still present, and the E2E test still checks all three capability masks while retaining CAP_NET_RAW. Its test count remains one before and after the PR. The performance pass found no material new hot-path cost: the filter is fixed-size and installed once. These checks do not establish effective packet filtering.
Already raised on this PR (not duplicated inline)
- High — behavioral packet-socket test missing (
introduced_by_change): the E2E assertion readsSeccomp: 2, which any inherited filter can provide; it never attemptssocket(AF_PACKET, ...). Existing discussionr4233182128covers this. RequireEPERMfor the denied socket and positive ICMP/ordinary-socket controls; ideally prove the test fails with this filter removed. - Medium — optional
--log-filefailure aborts all modes (introduced_by_change):main.rs:29-37now exits instead of warning, even for--available-backends. Existing discussionr4233182199covers this. - Medium —
io_uringsocket path is not covered (claim_mismatch): the added filter examinesSYS_socketonly, while Linuxio_socketcalls__sys_socket_filewithout a userspacesocket(2)call. Existing discussionr4233182073identifies this route. It predates this PR and is not, by itself, a new exploit attributed to the change or an independent merge condition; whether it is usable depends on host policy. It does contradict the PR's claim to deny packet-capable sockets.
Claim gaps and additional regression coverage
- Medium — legacy packet socket (
claim_mismatch, PR description): the claim that this denies RAW/PACKAGE sockets is wider than theAF_PACKET-family check. Linux__sock_createtranslatesPF_INET+SOCK_PACKETintoPF_PACKETafter seccomp sees the original family;CAP_NET_RAWremains available. Reject the legacy family/type pair or retain the limitation and test it. - Medium — AF_XDP (
claim_mismatch, PR description): a kernel with usable AF_XDP can createAF_XDP/SOCK_RAWwithCAP_NET_RAWand transmit link-layer frames; this family is allowed by the added filter. Actual bind/transmit availability depends on the host and other policy, so verify it in the supported environment before claiming this route is closed. Restrict it if the guarantee is meant to include all link-layer transmission. - Medium — BPF table regression seam (
introduced_by_change,lxc_bindings.rs:223-232): positional branch offsets determine allow/deny/kill. This is part of the behavioral-test blocker above rather than a separate blocking issue. A pure builder or isolated child-process test for native/foreign ABI, x32,AF_PACKET, legacy packet type, and ordinary sockets would make changes to that table reviewable. - Medium — CLI contract tests (
introduced_by_change,linux_executor_arguments.rs:94-115,main.rs:19-69): the refactor changes log failures, no-config errors and delete output with no targeted CLI tests. Add subprocess checks for exit code, stdout/stderr, mode precedence and invalid configuration. The separate parser logger and output regressions are also noted inline. - Low — ICMP harness diagnostic (
introduced_by_change,tests/scripts/run_lxc_network_ga_egress_test.sh:283-289): the deleted peer ping preflight previously distinguished an unreachable/echo-silent peer from firewall denial. The allowed ICMP test still fails, but now reports a less precise cause;MXC_PING_MISSINGis emitted without a harness check. Reinstate a reliable peer check or explicitly fail on that marker.
Verified pre-existing, not charged as introduced defects: Linux's PF_INET/SOCK_PACKET, io_uring socket creation and AF_XDP support predate this PR. They are listed above only as qualified mismatches with the PR's newly asserted security guarantee, capped at Medium. No finding is filed merely because unchanged code exists elsewhere. The signal-watchdog call also moved, but follow-up inspection found no Linux thread-spawning work before it; I am not filing a present-day signal bug on that basis.
The inline comments cover the newly introduced build failure, compat-ABI termination, seccomp-install prerequisite, parsing diagnostics and changed CLI outputs. The Windows feature-build failure was reproduced by cargo check -p lxc --features hyperlight; Linux LXC socket probes were not run on this Windows host.
| } | ||
|
|
||
| //KVM is checked before anything is pulled. | ||
| #[cfg(target_os = "linux")] |
There was a problem hiding this comment.
High (maintainability, cross-platform parity) — Windows x64 Hyperlight builds fail.
Attribution: introduced_by_change — this extracted helper now returns Result, but the setup/result expression is inside the Linux-only block. With hyperlight on Windows x64, the WHP check is the only remaining expression; its success path evaluates to (), producing E0317. cargo check -p lxc --features hyperlight reproduced this on Windows.
Fix: Keep OS-specific WHP/KVM preconditions separate, then run the shared parse_runtimes/setup code on both platforms (or provide a Windows branch returning Result).
| libc::sock_filter { code: JUMP_IF_EQUAL, jt: 1, jf: 0, k: libc::AF_PACKET as u32 }, // return error if using AF_PACKET | ||
| libc::sock_filter { code: RETURN, jt: 0, jf: 0, k: libc::SECCOMP_RET_ALLOW }, | ||
| libc::sock_filter { code: RETURN, jt: 0, jf: 0, k: REFUSE_WITH_EPERM }, | ||
| libc::sock_filter { code: RETURN, jt: 0, jf: 0, k: libc::SECCOMP_RET_KILL_PROCESS }, |
There was a problem hiding this comment.
Medium (reliability) — compat-ABI workloads are killed on their first syscall.
Attribution: introduced_by_change — this new SECCOMP_RET_KILL_PROCESS branch handles foreign audit architectures and x32 syscall numbers. A 32-bit program under an installed firewall can now die with SIGSYS before it opens any network socket, rather than receiving a meaningful sandbox-policy error. This may be a deliberate fail-closed policy, but it is neither documented nor exercised by the new test.
Fix: Document and test the supported ABI restriction with a useful diagnostic, or add a compat-ABI policy that still refuses packet sockets.
| let taken = unsafe { | ||
| libc::syscall( | ||
| libc::SYS_seccomp, | ||
| libc::SECCOMP_SET_MODE_FILTER as libc::c_ulong, |
There was a problem hiding this comment.
Medium (reliability) — filter installation adds an opaque privilege prerequisite.
Attribution: introduced_by_change — SECCOMP_SET_MODE_FILTER is new here. It requires CAP_SYS_ADMIN in the caller's user namespace or an already-set no_new_privs; the code does not set the latter. A caller with enough privilege for the existing PR_CAPBSET_DROP but not this new step now fails a firewalled run. The returned bare OS error does not identify which pre_exec step failed.
Fix: Document the new prerequisite, distinguish the seccomp failure without allocating unsafely in pre_exec, and test the failing-install path. Do not set no_new_privs indiscriminately because it changes privileged-exec behavior.
| (String::new(), false) | ||
| }; | ||
|
|
||
| let mut logger = Logger::new(if self.debug { |
There was a problem hiding this comment.
Medium (correctness) — request diagnostics no longer reach --log-file.
Attribution: introduced_by_change — get_request constructs a second logger with no file sink, then passes it to load_one_shot_request. Previously parsing used the logger configured in main, so validation warnings and parse errors were recorded in the requested log. The new logger is discarded, even when the CLI configured a file sink successfully.
Fix: Pass the already-configured logger into the request parser and preserve its diagnostics through the error path.
| } else if let Some(ref path) = self.config_path { | ||
| (path.clone(), false) | ||
| } else { | ||
| (String::new(), false) |
There was a problem hiding this comment.
Low (reliability) — no-argument invocation loses its actionable error.
Attribution: introduced_by_change — the old main explicitly exited with Error: No config provided. Use a positional path, --config, or --config-base64. This new fallback passes an empty string into the parser and emits a generic Request error instead.
Fix: Restore the explicit missing-config check and its usage guidance before calling load_one_shot_request.
| } else if arguments.does_user_want_to_delete_a_container() { | ||
| match arguments.delete_container() { | ||
| Ok(message) => { | ||
| eprintln!("{message}"); |
There was a problem hiding this comment.
Low (reliability) — delete-mode output moved from stdout/file log to stderr.
Attribution: introduced_by_change — the old delete path logged its result through the CLI logger and printed the buffer on stdout. This new success path prints only to stderr; the --log-file sink also misses the result. Automation reading the previous output stream sees no success message.
Fix: Preserve the old output/logging contract, or document and test an intentional CLI-breaking change.



📖 Description
Removing the CAP_NET_RAW capability allows a program to pass packets directly to the kernel and bypass the sandbox's networking rules. I decided to keep CAP_NET_RAW to allow LXC access to the ICMP protocol.
I have now found a way to deny a program the use of RAW and PACKAGE sockets while keeping ICMP. SecComp.
I made two changes
🔗 References
https://task.ms/64150389
🔍 Validation
Added automatic tests. Further, I manually tested the change. Because lxc effects bubblewrap and lxc I also ran the bubble wrap tests too.
✅ Checklist
Cargo.lock, thedependency-feed-checkcheck passes (see pull request builds)📋 Issue Type
GitHub Actions runs the PR validation build automatically. The ADO pipeline
(
MXC-PR-Build) is the Azure version of the PR pipeline, kept in parity with the GitHubActions build; it runs on merge to
main, and Microsoft reviewers with write access can trigger iton a PR with
/azp run. See pull request builds.If the
dependency-feed-checkcheck fails on a new dependency, the crate must be added tothe feed before the PR can pass. See pull request builds
for the steps.
Microsoft Reviewers: Open in CodeFlow