Security Best Practices
ProRT-IP implements defense-in-depth security with privilege dropping, input validation, DoS prevention, and secure coding practices to protect both the scanner and target networks.
Overview
Security Principles:
- Least Privilege: Drop privileges immediately after creating privileged resources
- Defense in Depth: Multiple layers of validation and error handling
- Fail Securely: Errors don't expose sensitive information or create vulnerabilities
- Input Validation: All external input is untrusted and must be validated
- Memory Safety: Leverage Rust's guarantees to prevent memory corruption
Threat Model:
Assets to protect:
- Scanner integrity (prevent exploitation)
- Network stability (avoid unintentional DoS)
- Confidential data (scan results may contain sensitive information)
- Host system (prevent privilege escalation or system compromise)
Threat actors:
- Malicious targets: Network hosts sending crafted responses to exploit scanner
- Malicious users: Operators attempting to abuse scanner for attacks
- Network defenders: IDS/IPS systems attempting to detect scanner
- Local attackers: Unprivileged users trying to escalate via scanner
Privilege Management
The Privilege Dropping Pattern
Critical: Raw packet capabilities are only needed during socket creation. Drop privileges immediately after.
Linux Capabilities (Recommended):
#![allow(unused)] fn main() { use nix::unistd::{setuid, setgid, setgroups, Uid, Gid}; use caps::{Capability, CapSet}; pub fn drop_privileges_safely(username: &str, groupname: &str) -> Result<()> { // Step 1: Clear supplementary groups (requires root) setgroups(&[])?; // Step 2: Drop group privileges let group = Group::from_name(groupname)? .ok_or(Error::GroupNotFound)?; setgid(Gid::from_raw(group.gid))?; // Step 3: Drop user privileges (irreversible) let user = User::from_name(username)? .ok_or(Error::UserNotFound)?; setuid(Uid::from_raw(user.uid))?; // Step 4: Verify we cannot regain privileges assert!(setuid(Uid::from_raw(0)).is_err(), "Failed to drop privileges!"); // Step 5: Drop remaining capabilities caps::clear(None, CapSet::Permitted)?; caps::clear(None, CapSet::Effective)?; tracing::info!("Privileges dropped to {}:{}", username, groupname); Ok(()) } }
Usage Pattern:
#![allow(unused)] fn main() { pub fn initialize_scanner() -> Result<Scanner> { // 1. Create privileged resources FIRST let raw_socket = create_raw_socket()?; // Requires CAP_NET_RAW let pcap_handle = open_pcap_capture()?; // Requires CAP_NET_RAW // 2. Drop privileges IMMEDIATELY drop_privileges_safely("scanner", "scanner")?; // 3. Continue with unprivileged operations let scanner = Scanner::new(raw_socket, pcap_handle)?; Ok(scanner) } }
Grant Capabilities Without setuid Root
Instead of making the binary setuid root (dangerous), grant only necessary capabilities:
# Build the binary
cargo build --release
# Grant specific capabilities (instead of setuid root)
sudo setcap cap_net_raw,cap_net_admin=eip target/release/prtip
# Verify
getcap target/release/prtip
# Output: target/release/prtip = cap_net_admin,cap_net_raw+eip
# Now runs without root
./target/release/prtip -sS -p 80,443 192.168.1.1
Benefits:
- No setuid root binary (eliminates entire attack vector)
- Capabilities automatically dropped after
execve() - More granular than full root access
- Standard Linux security best practice
Windows Privilege Management
Windows requires Administrator privileges for raw packet access:
#![allow(unused)] fn main() { #[cfg(target_os = "windows")] pub fn check_admin_privileges() -> Result<()> { use windows::Win32::Security::*; unsafe { let result = IsUserAnAdmin(); if result == FALSE { return Err(Error::InsufficientPrivileges( "Administrator privileges required for raw packet access on Windows" )); } } Ok(()) } }
Note: Windows does not support capability-based privilege models like Linux. Administrator access is all-or-nothing.
Input Validation
IP Address Validation
Always validate IP addresses using standard parsers:
#![allow(unused)] fn main() { use std::net::IpAddr; pub fn validate_ip_address(input: &str) -> Result<IpAddr> { // Use standard library parser (validates format) let ip = input.parse::<IpAddr>() .map_err(|_| Error::InvalidIpAddress(input.to_string()))?; // Additional checks match ip { IpAddr::V4(addr) => { // Reject unspecified/broadcast if addr.is_unspecified() || addr.is_broadcast() { return Err(Error::InvalidIpAddress("reserved address")); } Ok(IpAddr::V4(addr)) } IpAddr::V6(addr) => { if addr.is_unspecified() { return Err(Error::InvalidIpAddress("unspecified address")); } Ok(IpAddr::V6(addr)) } } } }
CIDR Validation
Prevent overly broad scans that could cause unintentional DoS:
#![allow(unused)] fn main() { use ipnetwork::IpNetwork; pub fn validate_cidr(input: &str) -> Result<IpNetwork> { let network = input.parse::<IpNetwork>() .map_err(|e| Error::InvalidCidr(input.to_string(), e))?; // Reject overly broad scans without confirmation match network { IpNetwork::V4(net) if net.prefix() < 8 => { return Err(Error::CidrTooBoard( "IPv4 networks larger than /8 require --confirm-large-scan" )); } IpNetwork::V6(net) if net.prefix() < 48 => { return Err(Error::CidrTooBoard( "IPv6 networks larger than /48 require --confirm-large-scan" )); } _ => Ok(network) } } }
Example:
# Rejected without confirmation
prtip -sS -p 80 0.0.0.0/0
# Error: IPv4 networks larger than /8 require --confirm-large-scan
# Allowed with confirmation
prtip -sS -p 80 0.0.0.0/0 --confirm-large-scan
# Scanning 4,294,967,296 hosts...
Port Range Validation
#![allow(unused)] fn main() { pub fn validate_port_range(start: u16, end: u16) -> Result<(u16, u16)> { if start == 0 { return Err(Error::InvalidPortRange("start port cannot be 0")); } if end < start { return Err(Error::InvalidPortRange("end port < start port")); } // Warn on full port scan if start == 1 && end == 65535 { tracing::warn!("Scanning all 65535 ports - this will take significant time"); } Ok((start, end)) } }
Filename Validation (Path Traversal Prevention)
Critical: Prevent path traversal attacks when accepting output file paths:
#![allow(unused)] fn main() { use std::path::{Path, PathBuf}; pub fn validate_output_path(path: &str) -> Result<PathBuf> { let path = Path::new(path); // Resolve to canonical path let canonical = path.canonicalize() .or_else(|_| { // If file doesn't exist yet, canonicalize parent let parent = path.parent() .ok_or(Error::InvalidPath("no parent directory"))?; let filename = path.file_name() .ok_or(Error::InvalidPath("no filename"))?; parent.canonicalize() .map(|p| p.join(filename)) })?; // Ensure path doesn't escape allowed directories let allowed_dirs = vec![ PathBuf::from("/tmp/prtip"), PathBuf::from("/var/lib/prtip"), std::env::current_dir()?, ]; let is_allowed = allowed_dirs.iter().any(|allowed| { canonical.starts_with(allowed) }); if !is_allowed { return Err(Error::PathTraversalAttempt(canonical)); } // Reject suspicious patterns let path_str = canonical.to_string_lossy(); if path_str.contains("..") || path_str.contains('\0') { return Err(Error::SuspiciousPath(path_str.to_string())); } Ok(canonical) } }
Command Injection Prevention
Never construct shell commands from user input!
#![allow(unused)] fn main() { use std::process::Command; // ❌ WRONG: Vulnerable to command injection fn resolve_hostname_unsafe(hostname: &str) -> Result<String> { let output = Command::new("sh") .arg("-c") .arg(format!("nslookup {}", hostname)) // DANGER! .output()?; // Attacker input: "example.com; rm -rf /" // Executes: nslookup example.com; rm -rf / } // ✅ CORRECT: Direct process spawn, no shell interpretation fn resolve_hostname_safe(hostname: &str) -> Result<String> { let output = Command::new("nslookup") .arg(hostname) // Passed as separate argument .output()?; String::from_utf8(output.stdout) .map_err(|e| Error::Utf8Error(e)) } // ✅ BEST: Use Rust library instead of external command fn resolve_hostname_best(hostname: &str) -> Result<IpAddr> { use trust_dns_resolver::Resolver; let resolver = Resolver::from_system_conf()?; let response = resolver.lookup_ip(hostname)?; let addr = response.iter().next() .ok_or(Error::NoAddressFound)?; Ok(addr) } }
Packet Parsing Safety
Safe Packet Parsing Pattern
Critical: Malicious targets can send crafted packets to exploit parsing bugs. Always validate before accessing data.
#![allow(unused)] fn main() { pub fn parse_tcp_packet_safe(data: &[u8]) -> Option<TcpHeader> { // 1. Explicit length check BEFORE any access if data.len() < 20 { tracing::warn!("TCP packet too short: {} bytes", data.len()); return None; } // 2. Use safe indexing or validated slices let src_port = u16::from_be_bytes([data[0], data[1]]); let dst_port = u16::from_be_bytes([data[2], data[3]]); let seq = u32::from_be_bytes([data[4], data[5], data[6], data[7]]); let ack = u32::from_be_bytes([data[8], data[9], data[10], data[11]]); // 3. Validate data offset field before trusting it let data_offset_raw = data[12] >> 4; let data_offset = (data_offset_raw as usize) * 4; if data_offset < 20 { tracing::warn!("Invalid TCP data offset: {}", data_offset); return None; } if data_offset > data.len() { tracing::warn!( "TCP data offset {} exceeds packet length {}", data_offset, data.len() ); return None; } // 4. Parse flags safely let flags = TcpFlags::from_bits_truncate(data[13]); // 5. Return structured data Some(TcpHeader { src_port, dst_port, seq, ack, flags, data_offset, }) } }
Error Handling for Malformed Packets
#![allow(unused)] fn main() { // ❌ WRONG: panic! in packet parsing fn parse_packet_wrong(data: &[u8]) -> TcpPacket { assert!(data.len() >= 20, "Packet too short!"); // PANIC! // Attacker sends 10-byte packet → process crashes } // ✅ CORRECT: Return Option/Result fn parse_packet_correct(data: &[u8]) -> Option<TcpPacket> { if data.len() < 20 { return None; // Graceful handling } // ... continue parsing } // ✅ BETTER: Log and continue fn parse_packet_better(data: &[u8]) -> Option<TcpPacket> { if data.len() < 20 { tracing::debug!( "Ignoring short packet ({} bytes)", data.len() ); return None; } // ... continue parsing } }
Why This Matters: Malicious targets can send malformed packets to crash the scanner. Network scanning tools are common targets for defensive denial-of-service attacks.
Using pnet for Safe Parsing
The pnet crate provides bounds-checked packet parsing:
#![allow(unused)] fn main() { use pnet::packet::tcp::{TcpPacket, TcpFlags}; pub fn parse_with_pnet(data: &[u8]) -> Option<TcpInfo> { // pnet performs bounds checking automatically let tcp = TcpPacket::new(data)?; // Returns None if invalid Some(TcpInfo { src_port: tcp.get_source(), dst_port: tcp.get_destination(), flags: tcp.get_flags(), // ... other fields }) } }
Advantage: Eliminates entire class of buffer overflow bugs by construction.
DoS Prevention
Rate Limiting
Prevent scanner from overwhelming target networks:
#![allow(unused)] fn main() { use governor::{Quota, RateLimiter, clock::DefaultClock}; use std::num::NonZeroU32; pub struct ScanRateLimiter { limiter: RateLimiter<DefaultClock>, max_rate: u32, } impl ScanRateLimiter { pub fn new(packets_per_second: u32) -> Self { let quota = Quota::per_second(NonZeroU32::new(packets_per_second).unwrap()); let limiter = RateLimiter::direct(quota); Self { limiter, max_rate: packets_per_second, } } pub async fn wait_for_permit(&self) { self.limiter.until_ready().await; } } // Usage in scanning loop let rate_limiter = ScanRateLimiter::new(100_000); // 100K pps max for target in targets { rate_limiter.wait_for_permit().await; send_packet(target).await?; } }
Default Rate Limits:
-T0(Paranoid): 100 pps-T1(Sneaky): 500 pps-T2(Polite): 2,000 pps-T3(Normal): 10,000 pps (default)-T4(Aggressive): 50,000 pps-T5(Insane): 100,000 pps
See Rate Limiting for comprehensive rate control documentation.
Connection Limits
Prevent resource exhaustion from too many concurrent connections:
#![allow(unused)] fn main() { use tokio::sync::Semaphore; pub struct ConnectionLimiter { semaphore: Arc<Semaphore>, max_connections: usize, } impl ConnectionLimiter { pub fn new(max_connections: usize) -> Self { Self { semaphore: Arc::new(Semaphore::new(max_connections)), max_connections, } } pub async fn acquire(&self) -> SemaphorePermit<'_> { self.semaphore.acquire().await.unwrap() } } // Usage let limiter = ConnectionLimiter::new(1000); // Max 1000 concurrent for target in targets { let _permit = limiter.acquire().await; // Blocks if limit reached tokio::spawn(async move { scan_target(target).await; // _permit dropped here, slot freed }); } }
Memory Limits
Stream results to disk to prevent unbounded memory growth:
#![allow(unused)] fn main() { pub struct ResultBuffer { buffer: Vec<ScanResult>, max_size: usize, flush_tx: mpsc::Sender<Vec<ScanResult>>, } impl ResultBuffer { pub fn push(&mut self, result: ScanResult) -> Result<()> { self.buffer.push(result); // Flush when buffer reaches limit if self.buffer.len() >= self.max_size { self.flush()?; } Ok(()) } fn flush(&mut self) -> Result<()> { if self.buffer.is_empty() { return Ok(()); } let batch = std::mem::replace(&mut self.buffer, Vec::new()); self.flush_tx.send(batch) .map_err(|_| Error::FlushFailed)?; Ok(()) } } }
Memory Management Strategy:
- Batch results in chunks of 1,000-10,000
- Stream to disk/database immediately
- Bounded memory usage regardless of scan size
Scan Duration Limits
Prevent infinite scans from consuming resources:
#![allow(unused)] fn main() { pub struct ScanExecutor { config: ScanConfig, start_time: Instant, } impl ScanExecutor { pub async fn execute(&self) -> Result<ScanReport> { let timeout = self.config.max_duration .unwrap_or(Duration::from_secs(3600)); // Default 1 hour tokio::select! { result = self.run_scan() => { result } _ = tokio::time::sleep(timeout) => { Err(Error::ScanTimeout(timeout)) } } } } }
Secrets Management
Configuration Files
Ensure configuration files containing secrets have secure permissions:
#![allow(unused)] fn main() { use std::fs::{Permissions, set_permissions}; use std::os::unix::fs::PermissionsExt; pub struct Config { pub api_key: Option<String>, pub database_url: Option<String>, } impl Config { pub fn load(path: &Path) -> Result<Self> { // Check file permissions let metadata = std::fs::metadata(path)?; let permissions = metadata.permissions(); #[cfg(unix)] { let mode = permissions.mode(); // Must be 0600 or 0400 (owner read/write or owner read-only) if mode & 0o077 != 0 { return Err(Error::InsecureConfigPermissions( format!("Config file {:?} has insecure permissions: {:o}", path, mode) )); } } // Load and parse config let contents = std::fs::read_to_string(path)?; let config: Config = toml::from_str(&contents)?; Ok(config) } pub fn save(&self, path: &Path) -> Result<()> { let contents = toml::to_string_pretty(self)?; std::fs::write(path, contents)?; // Set secure permissions #[cfg(unix)] { let perms = Permissions::from_mode(0o600); set_permissions(path, perms)?; } Ok(()) } } }
Environment Variables (Preferred)
Best Practice: Use environment variables for sensitive configuration:
#![allow(unused)] fn main() { use std::env; pub struct Credentials { pub db_password: String, pub api_key: Option<String>, } impl Credentials { pub fn from_env() -> Result<Self> { let db_password = env::var("PRTIP_DB_PASSWORD") .map_err(|_| Error::MissingCredential("PRTIP_DB_PASSWORD"))?; let api_key = env::var("PRTIP_API_KEY").ok(); Ok(Self { db_password, api_key, }) } } // Usage let creds = Credentials::from_env()?; let db = connect_database(&creds.db_password)?; }
Example:
# Set environment variables
export PRTIP_DB_PASSWORD="secret123"
export PRTIP_API_KEY="api-key-xyz"
# Run scanner
prtip -sS -p 80,443 192.168.1.0/24 --with-db
Never Log Secrets
Critical: Ensure secrets never appear in logs:
#![allow(unused)] fn main() { use tracing::{info, warn}; // ❌ WRONG: Logs password info!("Connecting to database with password: {}", password); // ✅ CORRECT: Redact secrets info!("Connecting to database at {}", db_url.host()); // ✅ BETTER: Use structured logging with filtering info!( db_host = %db_url.host(), db_port = db_url.port(), "Connecting to database" ); // Password field omitted entirely }
Secure Coding Practices
1. Avoid Integer Overflows
#![allow(unused)] fn main() { // ❌ WRONG: Can overflow fn calculate_buffer_size(count: u32, size_per_item: u32) -> usize { (count * size_per_item) as usize // May wrap around! } // ✅ CORRECT: Check for overflow fn calculate_buffer_size_safe(count: u32, size_per_item: u32) -> Result<usize> { count.checked_mul(size_per_item) .ok_or(Error::IntegerOverflow)? .try_into() .map_err(|_| Error::IntegerOverflow) } // ✅ BETTER: Use saturating arithmetic when wrapping is acceptable fn calculate_buffer_size_saturating(count: u32, size_per_item: u32) -> usize { count.saturating_mul(size_per_item) as usize } }
2. Prevent Time-of-Check to Time-of-Use (TOCTOU)
#![allow(unused)] fn main() { // ❌ WRONG: File could change between check and open if Path::new(&filename).exists() { let file = File::open(&filename)?; // TOCTOU race! } // ✅ CORRECT: Open directly and handle error let file = match File::open(&filename) { Ok(f) => f, Err(e) if e.kind() == io::ErrorKind::NotFound => { return Err(Error::FileNotFound(filename)); } Err(e) => return Err(Error::IoError(e)), }; }
3. Secure Random Number Generation
#![allow(unused)] fn main() { use rand::rngs::OsRng; use rand::RngCore; // ✅ CORRECT: Cryptographically secure RNG fn generate_sequence_number() -> u32 { let mut rng = OsRng; rng.next_u32() } // ❌ WRONG: Thread RNG not cryptographically secure fn generate_sequence_number_weak() -> u32 { use rand::thread_rng; let mut rng = thread_rng(); rng.next_u32() // Predictable for security purposes! } }
Why This Matters: TCP sequence numbers, UDP source ports, and other protocol fields should be unpredictable to prevent spoofing and session hijacking attacks.
4. Constant-Time Comparisons (for secrets)
#![allow(unused)] fn main() { use subtle::ConstantTimeEq; // ✅ CORRECT: Constant-time comparison prevents timing attacks fn verify_api_key(provided: &str, expected: &str) -> bool { provided.as_bytes().ct_eq(expected.as_bytes()).into() } // ❌ WRONG: Early exit on mismatch leaks information via timing fn verify_api_key_weak(provided: &str, expected: &str) -> bool { provided == expected // Timing attack vulnerable! } }
Security Audit Checklist
Pre-Release Security Audit
Privilege Management:
- Privileges dropped immediately after socket creation
- Cannot regain elevated privileges after dropping
- Capabilities documented and minimal
- No setuid root binaries (use capabilities instead)
Input Validation:
- All user input validated with allowlists
- Path traversal attempts rejected
- No command injection vectors
- CIDR ranges size-limited
- Port ranges validated (1-65535)
Packet Parsing:
- All packet parsers handle malformed input
- No panics in packet parsing code
- Length fields validated before use
- No buffer overruns possible
-
Using
pnetor equivalent bounds-checked libraries
Resource Limits:
- Rate limiting enforced
- Connection limits enforced
- Memory usage bounded (streaming to disk)
- Scan duration limits enforced
Secrets Management:
- No hardcoded credentials
- Config files have secure permissions (0600)
- Secrets not logged
- Environment variables used for sensitive data
Dependencies:
-
cargo auditpasses with no criticals - All dependencies from crates.io (no git deps)
- SBOM (Software Bill of Materials) generated
- Dependency versions pinned in Cargo.lock
Fuzzing:
- Packet parsers fuzzed for 24+ hours
- CLI argument parsing fuzzed
- Configuration file parsing fuzzed
- 0 crashes in fuzzing runs
Code Review:
-
No
unsafeblocks without justification -
All
unsafeblocks audited - No TODO/FIXME in security-critical code
- Clippy warnings resolved
Running Security Audits
Dependency Audit:
# Install cargo-audit
cargo install cargo-audit
# Run audit
cargo audit
# Check specific advisories
cargo audit --deny warnings
Fuzzing:
# Install cargo-fuzz
cargo install cargo-fuzz
# Fuzz packet parsers (run for 24+ hours)
cargo fuzz run tcp_parser -- -max_total_time=86400
cargo fuzz run udp_parser -- -max_total_time=86400
cargo fuzz run icmp_parser -- -max_total_time=86400
See Fuzzing for comprehensive fuzzing guide.
Static Analysis:
# Clippy with strict lints
cargo clippy -- -D warnings
# Check for common security issues
cargo clippy -- -W clippy::arithmetic_side_effects \
-W clippy::integer_overflow \
-W clippy::panic \
-W clippy::unwrap_used
Responsible Disclosure
If you discover a security vulnerability in ProRT-IP:
- Do not disclose publicly until coordinated disclosure timeline agreed
- Report via GitHub Security Advisories
- Include:
- Vulnerability description
- Steps to reproduce
- Affected versions
- Suggested fix (if known)
Response Timeline:
- Acknowledgment within 48 hours
- Severity assessment within 1 week
- Fix development coordination
- Public disclosure after fix released
See Also
- Testing Strategy - Security testing methodologies
- Architecture - Security boundaries and design
- Fuzzing - Comprehensive fuzzing guide
- Rate Limiting - DoS prevention and rate control