Skip to content

Commit 14b305b

Browse files
authored
feat: add restricted token sandbox for Windows (#206)
* feat: add restricted token sandbox for Windows
1 parent eec74e4 commit 14b305b

11 files changed

Lines changed: 560 additions & 32 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ windows = { version = "0.62", features = [
5858
"Win32_NetworkManagement_Ndis",
5959
"Win32_Networking_WinSock",
6060
"Win32_Security",
61+
"Win32_System_JobObjects",
6162
"Win32_System_LibraryLoader",
6263
"Win32_System_Threading",
6364
] }

SECURITY.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ RustNet processes untrusted network data, making defense-in-depth security criti
77
- [Landlock Sandboxing (Linux)](#landlock-sandboxing-linux)
88
- [Seatbelt Sandboxing (macOS)](#seatbelt-sandboxing-macos)
99
- [FreeBSD Sandboxing](#freebsd-sandboxing)
10+
- [Restricted Token Sandboxing (Windows)](#restricted-token-sandboxing-windows)
1011
- [Privilege Requirements](#privilege-requirements)
1112
- [Read-Only Operation](#read-only-operation)
1213
- [No External Communication](#no-external-communication)
@@ -121,6 +122,46 @@ On Linux, clipboard requires access to Wayland sockets (`/run/user/UID/wayland-0
121122

122123
FreeBSD does not currently have sandboxing enabled. A full Capsicum sandbox using `cap_enter()` with `libcasper` for privileged process lookup is planned — see [ROADMAP.md](ROADMAP.md) for details.
123124

125+
## Restricted Token Sandboxing (Windows)
126+
127+
On Windows, RustNet removes dangerous privileges from the process token and applies a Job Object to prevent child process creation after initialization.
128+
129+
### What Gets Restricted
130+
131+
| Restriction | Description |
132+
|-------------|-------------|
133+
| Privilege removal | SeDebugPrivilege, SeTakeOwnershipPrivilege, SeBackupPrivilege, SeRestorePrivilege, and other dangerous privileges permanently removed |
134+
| Child processes | Job Object blocks creation of child processes (reverse shell, exec-based exfiltration) |
135+
136+
### How It Works
137+
138+
1. **Initialization phase**: RustNet opens Npcap handles and creates log files
139+
2. **Privilege removal**: `AdjustTokenPrivileges` with `SE_PRIVILEGE_REMOVED` permanently strips dangerous privileges from the process token
140+
3. **Job Object**: A Job Object with `JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 1` is applied, preventing any child process creation
141+
142+
### Security Benefits
143+
144+
If an attacker exploits a vulnerability in DPI/packet parsing:
145+
- Cannot debug other processes (SeDebugPrivilege removed)
146+
- Cannot take ownership of arbitrary files (SeTakeOwnershipPrivilege removed)
147+
- Cannot bypass ACLs to read files (SeBackupPrivilege removed)
148+
- Cannot spawn child processes (cmd.exe, powershell.exe, curl.exe — blocked by Job Object)
149+
- Cannot load kernel drivers (SeLoadDriverPrivilege removed)
150+
151+
### Limitations
152+
153+
Windows sandboxing is weaker than Linux/macOS/FreeBSD:
154+
- No filesystem restriction — Windows lacks a process-wide filesystem sandbox equivalent to Landlock or Seatbelt
155+
- No network restriction — blocking outbound would break Npcap packet capture
156+
- Privilege removal only affects privileges the elevated process held
157+
158+
### CLI Options
159+
160+
```
161+
--no-sandbox Disable privilege removal and job object
162+
--sandbox-strict Require full sandbox enforcement or exit
163+
```
164+
124165
## Privilege Requirements
125166

126167
RustNet requires privileged access for packet capture:

src/app.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,18 @@ use std::sync::{LazyLock, Mutex};
4545
/// Sandbox status information for UI display
4646
#[cfg(any(
4747
target_os = "linux",
48+
target_os = "windows",
4849
all(target_os = "macos", feature = "macos-sandbox")
4950
))]
5051
#[derive(Debug, Clone, Default)]
5152
pub struct SandboxInfo {
5253
/// Overall status description
5354
pub status: String,
5455
/// Whether network connections are blocked
56+
#[cfg(any(
57+
target_os = "linux",
58+
all(target_os = "macos", feature = "macos-sandbox")
59+
))]
5560
pub net_restricted: bool,
5661
// Linux-specific fields (Landlock + capabilities)
5762
/// Whether CAP_NET_RAW was dropped
@@ -73,6 +78,16 @@ pub struct SandboxInfo {
7378
/// Whether filesystem write restrictions are applied
7479
#[cfg(all(target_os = "macos", feature = "macos-sandbox"))]
7580
pub fs_restricted: bool,
81+
// Windows-specific fields (Restricted token + Job Object)
82+
/// Whether dangerous privileges were removed
83+
#[cfg(target_os = "windows")]
84+
pub privileges_removed: bool,
85+
/// Number of privileges removed
86+
#[cfg(target_os = "windows")]
87+
pub privileges_removed_count: u32,
88+
/// Whether job object was applied
89+
#[cfg(target_os = "windows")]
90+
pub job_object_applied: bool,
7691
}
7792

7893
/// Process detection status information for UI display
@@ -455,9 +470,10 @@ pub struct App {
455470
/// GeoIP resolver for location/ASN lookups
456471
geoip_resolver: Option<Arc<GeoIpResolver>>,
457472

458-
/// Sandbox status (Linux Landlock / macOS Seatbelt)
473+
/// Sandbox status (Linux Landlock / macOS Seatbelt / Windows restricted token)
459474
#[cfg(any(
460475
target_os = "linux",
476+
target_os = "windows",
461477
all(target_os = "macos", feature = "macos-sandbox")
462478
))]
463479
sandbox_info: Arc<RwLock<SandboxInfo>>,
@@ -563,6 +579,7 @@ impl App {
563579
geoip_resolver,
564580
#[cfg(any(
565581
target_os = "linux",
582+
target_os = "windows",
566583
all(target_os = "macos", feature = "macos-sandbox")
567584
))]
568585
sandbox_info: Arc::new(RwLock::new(SandboxInfo::default())),
@@ -1767,6 +1784,7 @@ impl App {
17671784
/// Get sandbox status information
17681785
#[cfg(any(
17691786
target_os = "linux",
1787+
target_os = "windows",
17701788
all(target_os = "macos", feature = "macos-sandbox")
17711789
))]
17721790
pub fn get_sandbox_info(&self) -> SandboxInfo {
@@ -1779,6 +1797,7 @@ impl App {
17791797
/// Set sandbox status information
17801798
#[cfg(any(
17811799
target_os = "linux",
1800+
target_os = "windows",
17821801
all(target_os = "macos", feature = "macos-sandbox")
17831802
))]
17841803
pub fn set_sandbox_info(&self, info: SandboxInfo) {

src/cli.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ pub fn build_cli() -> Command {
140140

141141
#[cfg(any(
142142
target_os = "linux",
143+
target_os = "windows",
143144
all(target_os = "macos", feature = "macos-sandbox")
144145
))]
145146
let cmd = cmd

src/main.rs

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -134,15 +134,19 @@ fn main() -> Result<()> {
134134
// This must be done BEFORE Landlock is applied so the file exists when adding rules
135135
if let Some(ref pcap_path) = config.pcap_export_file {
136136
let jsonl_path = format!("{}.connections.jsonl", pcap_path);
137-
if let Err(e) = std::fs::File::create(&jsonl_path).and_then(|f| {
138-
#[cfg(unix)]
139-
{
140-
use std::os::unix::fs::PermissionsExt;
141-
f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
137+
match std::fs::File::create(&jsonl_path) {
138+
Ok(_f) => {
139+
#[cfg(unix)]
140+
{
141+
use std::os::unix::fs::PermissionsExt;
142+
if let Err(e) = _f.set_permissions(std::fs::Permissions::from_mode(0o600)) {
143+
warn!("Failed to set sidecar JSONL file permissions: {}", e);
144+
}
145+
}
146+
}
147+
Err(e) => {
148+
warn!("Failed to pre-create sidecar JSONL file: {}", e);
142149
}
143-
Ok(f)
144-
}) {
145-
warn!("Failed to pre-create sidecar JSONL file: {}", e);
146150
}
147151
}
148152

@@ -342,6 +346,65 @@ fn main() -> Result<()> {
342346
}
343347
}
344348

349+
// Apply restricted token sandbox (Windows only)
350+
// This must be done AFTER app.start() because:
351+
// - Npcap handles need to be opened first
352+
// - Log files need to be created first
353+
#[cfg(target_os = "windows")]
354+
{
355+
use network::platform::sandbox::{
356+
SandboxConfig, SandboxMode, SandboxStatus, apply_sandbox,
357+
};
358+
359+
let sandbox_mode = if matches.get_flag("no-sandbox") {
360+
SandboxMode::Disabled
361+
} else if matches.get_flag("sandbox-strict") {
362+
SandboxMode::Strict
363+
} else {
364+
SandboxMode::BestEffort
365+
};
366+
367+
let sandbox_config = SandboxConfig { mode: sandbox_mode };
368+
369+
match apply_sandbox(&sandbox_config) {
370+
Ok(result) => {
371+
let status_str = match result.status {
372+
SandboxStatus::FullyEnforced => {
373+
info!("Windows sandbox fully enforced: {}", result.message);
374+
"Fully enforced"
375+
}
376+
SandboxStatus::PartiallyEnforced => {
377+
warn!("Windows sandbox partially enforced: {}", result.message);
378+
"Partially enforced"
379+
}
380+
SandboxStatus::NotApplied => {
381+
warn!("Windows sandbox not applied: {}", result.message);
382+
"Not applied"
383+
}
384+
};
385+
386+
app.set_sandbox_info(app::SandboxInfo {
387+
status: status_str.to_string(),
388+
privileges_removed: result.privileges_removed,
389+
privileges_removed_count: result.privileges_removed_count,
390+
job_object_applied: result.job_object_applied,
391+
});
392+
}
393+
Err(e) => {
394+
if sandbox_mode == SandboxMode::Strict {
395+
return Err(e.context("Windows sandbox enforcement required but failed"));
396+
}
397+
warn!("Windows sandbox error (non-strict mode): {}", e);
398+
app.set_sandbox_info(app::SandboxInfo {
399+
status: "Error".to_string(),
400+
privileges_removed: false,
401+
privileges_removed_count: 0,
402+
job_object_applied: false,
403+
});
404+
}
405+
}
406+
}
407+
345408
// Run the UI loop
346409
let res = run_ui_loop(&mut terminal, &app);
347410

src/network/capture.rs

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -523,19 +523,18 @@ mod tests {
523523
fn test_udp_routing_resolution_can_execute() {
524524
// Sanity-check test to ensure the OS handles UDP metric routing cleanly.
525525
// It's perfectly fine if this fails in hermetic CI environments without outbound routes.
526-
if let Ok(socket) = std::net::UdpSocket::bind("0.0.0.0:0") {
527-
if socket.connect("8.8.8.8:53").is_ok() {
528-
if let Ok(addr) = socket.local_addr() {
529-
assert!(
530-
!addr.ip().is_loopback(),
531-
"Active routed IP should not be loopback"
532-
);
533-
assert!(
534-
!addr.ip().is_unspecified(),
535-
"Active routed IP should not be unspecified"
536-
);
537-
}
538-
}
526+
if let Ok(socket) = std::net::UdpSocket::bind("0.0.0.0:0")
527+
&& socket.connect("8.8.8.8:53").is_ok()
528+
&& let Ok(addr) = socket.local_addr()
529+
{
530+
assert!(
531+
!addr.ip().is_loopback(),
532+
"Active routed IP should not be loopback"
533+
);
534+
assert!(
535+
!addr.ip().is_unspecified(),
536+
"Active routed IP should not be unspecified"
537+
);
539538
}
540539
}
541540
}

src/network/platform/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ pub use macos::sandbox;
115115
#[cfg(target_os = "macos")]
116116
pub use macos::{MacOSStatsProvider, create_process_lookup};
117117
#[cfg(target_os = "windows")]
118-
pub use windows::{WindowsProcessLookup, WindowsStatsProvider, create_process_lookup};
118+
pub use windows::sandbox;
119+
#[cfg(target_os = "windows")]
120+
pub use windows::{WindowsStatsProvider, create_process_lookup};
119121

120122
/// Trait for platform-specific process lookup
121123
pub trait ProcessLookup: Send + Sync {

src/network/platform/windows/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
mod interface_stats;
44
mod process;
5+
pub mod sandbox;
56

67
pub use interface_stats::WindowsStatsProvider;
78
pub use process::WindowsProcessLookup;

0 commit comments

Comments
 (0)