Sandboxing Network Tools with Landlock
Network monitoring tools like RustNet process untrusted data from the wire. This makes them vulnerable to protocol exploits and crafted payloads. If an attacker finds a bug in packet parsing, they could read sensitive files, steal data, or open reverse shells. This has happened with Wireshark before, and since we use pcap, we could face the same issues. This got me thinking about defense-in-depth: what if the tool could sandbox itself after initialization?
The Problem with Privileged Tools
Network capture tools require elevated privileges. On Linux, CAP_NET_RAW allows creating raw sockets to capture packets. But once you have this capability, you usually keep it for the entire process lifetime—even though you only need it during initialization.
1
2
3
# Traditional approach: run with elevated privileges
sudo ./network-tool --interface eth0
# Tool now has CAP_NET_RAW for its entire lifetime
This creates a larger attack surface than necessary. If a bug in Deep Packet Inspection (DPI) code is exploited, the attacker inherits all the privileges the process has.
Enter Landlock
Landlock is a Linux Security Module (LSM) that allows unprivileged sandboxing. Unlike seccomp (which filters syscalls) or namespaces (which need privileges to set up), Landlock lets a process restrict itself. It’s been in the kernel since 5.13 (filesystem), and network restrictions were added in 6.4.
The key insight: sandbox after initialization, not before. We can:
- Open packet capture handles (needs
CAP_NET_RAW) - Load eBPF programs (needs
CAP_BPF) - Create log files (needs filesystem write access)
- Then apply Landlock restrictions
- Then drop
CAP_NET_RAW
The existing pcap handle remains valid—the kernel doesn’t revoke it. But new raw sockets? Blocked.
Implementation
The Landlock API involves creating a ruleset, adding rules, and enforcing it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
pub fn apply_sandbox(config: &SandboxConfig) -> Result<SandboxResult> {
// Check kernel support
let abi_version = landlock_create_ruleset(
ptr::null(),
0,
LANDLOCK_CREATE_RULESET_VERSION
);
if abi_version < 0 {
return Ok(SandboxResult::not_available("Landlock not supported"));
}
// Create ruleset with desired restrictions
let ruleset_attr = landlock_ruleset_attr {
handled_access_fs: LANDLOCK_ACCESS_FS_EXECUTE
| LANDLOCK_ACCESS_FS_READ_FILE
| LANDLOCK_ACCESS_FS_WRITE_FILE
| /* ... more flags ... */,
handled_access_net: LANDLOCK_ACCESS_NET_BIND_TCP
| LANDLOCK_ACCESS_NET_CONNECT_TCP,
};
let ruleset_fd = landlock_create_ruleset(&ruleset_attr, ...);
// Allow reading /proc (needed for process identification)
add_path_rule(ruleset_fd, "/proc", LANDLOCK_ACCESS_FS_READ_FILE)?;
// Allow writing to configured log paths
for path in &config.allowed_write_paths {
add_path_rule(ruleset_fd, path, LANDLOCK_ACCESS_FS_WRITE_FILE)?;
}
// Enforce the ruleset
landlock_restrict_self(ruleset_fd, 0)?;
Ok(SandboxResult::success())
}
We allow /proc reads (needed for process lookup) but block everything else. Network restrictions block TCP bind/connect entirely. RustNet is a passive monitor, so it doesn’t need outbound connections.
Dropping Capabilities
After the sandbox is applied, we drop CAP_NET_RAW:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pub fn drop_cap_net_raw() -> Result<bool> {
let mut caps = CapSet::empty();
// Read current capabilities
capget(&mut header, &mut caps)?;
// Clear CAP_NET_RAW from all sets
caps.effective &= !(1 << CAP_NET_RAW);
caps.permitted &= !(1 << CAP_NET_RAW);
caps.inheritable &= !(1 << CAP_NET_RAW);
// Apply
capset(&header, &caps)?;
Ok(true)
}
Is This Actually Useful?
How much do we gain from this? I’m honestly not sure.
The problem is CAP_BPF. We can’t drop it because RustNet uses eBPF for process lookup - mapping network connections to processes with low overhead. We could fall back to procfs scanning, but that’s slower and we’d lose some functionality.
So even after sandboxing, an attacker who exploits RustNet still has:
CAP_BPF- a powerful capability that allows loading eBPF programs- The open pcap handle - they can still capture packets
- Read access to
/proc- process information is still available
In the worst case, Landlock is just another layer that doesn’t actually stop a determined attacker. If someone finds a way around the filesystem restrictions, they’re back to having most of what they need.
That said, defense-in-depth is about raising the bar, not building perfect walls. Landlock blocks the easy paths: no writing to arbitrary files, no opening network connections, no executing binaries. An attacker now needs a Landlock bypass on top of their initial exploit. That’s harder than just having full access from the start.
Is it worth the added complexity? I think so, but I’m not completely convinced.
Graceful Degradation
Not all environments support Landlock:
- Kernel < 5.13: No Landlock support—continue without sandbox
- Kernel 5.13-6.3: Filesystem restrictions only, no network
- Kernel 6.4+: Full filesystem + network restrictions
- Docker/containers: seccomp may block
landlock_*syscalls
The tool checks what’s available and applies what it can:
1
2
3
4
5
6
7
8
9
let result = apply_sandbox(&config);
match result.status {
SandboxStatus::FullyEnforced => info!("Sandbox fully applied"),
SandboxStatus::PartiallyEnforced => warn!("Partial sandbox: {}", result.details),
SandboxStatus::NotAvailable => warn!("Sandboxing unavailable: {}", result.reason),
}
// Continue running either way—don't fail on missing sandbox
For high-security environments, a --sandbox-strict flag makes the tool exit if full enforcement isn’t possible.
The UI Feedback Loop
I wanted to see the sandbox status clearly. RustNet’s TUI now shows:
1
2
3
4
┌─Security────────────────────────────────────────┐
│ Landlock: Fully enforced [kernel supported] |
│ CAP_NET_RAW dropped, FS restricted, Net blocked |
└─────────────────────────────────────────────────┘
This makes it clear whether Landlock is active.