DEV Community

Bored
Bored

Posted on

Building a High-Performance Live Network Sniffer in Rust (Without Kernel Drivers)

Network traffic analysis is a superpower. Whether you are debugging a distributed system, reverse-engineering a legacy protocol, or performing security auditing, you usually end up opening Wireshark.

But what if you want to automate that detection? What if you need to trigger a specific action the moment a specific text sequence—like a specific username, a specialized API key, or a magic header—hits the network card?

Writing a kernel-level driver to capture packets is painful and dangerous (one bug = Blue Screen of Death). Using raw socket libraries (like libpcap) is powerful but can be a nightmare regarding cross-platform compilation (Windows headers vs. Linux headers).

In this article, I’ll explain how I built a Rust-based CLI tool that wraps the power of TShark (Wireshark) to monitor live traffic, filter by IP, and detect hidden ASCII and Wide-Character signatures in real-time.

The Goal

I needed a tool that could:

  1. Run on Windows and Linux.
  2. Require no custom drivers (piggyback off an existing Wireshark installation).
  3. Filter for a specific target IP.
  4. Scan packet payloads for a specific signature (e.g., "BunnyName123").
  5. Sanitize the messy binary data into clean, readable UTF-8 logs.

The Architecture: "The Sidecar Pattern"

Instead of implementing the full TCP/IP stack in Rust, I used the "Sidecar" approach. We spawn TShark (the command-line version of Wireshark) as a child process and pipe its output directly into our Rust application's standard input.

  • TShark: Handles the interface selection, promiscuous mode, and raw capture.
  • Rust: Handles the stream buffering, parsing, signature matching, and file logging.

This gives us the stability of Wireshark’s dissection engine with the safety and speed of Rust.

Challenge #1: Finding the Binary (Windows Pathing)

The first hurdle in cross-platform development is that Linux users have tshark in their global PATH, but Windows users usually don't (it sits in C:\Program Files\...).

If you simply run Command::new("tshark") on a standard Windows install, it will panic. I solved this with a robust resolver function that checks the system PATH first, and then iterates through standard installation directories:

fn resolve_tshark_path() -> String {
    let common_paths = [
        r"C:\Program Files\Wireshark\tshark.exe",
        r"C:\Program Files (x86)\Wireshark\tshark.exe",
        "/usr/bin/tshark", 
    ];

    // 1. Try the PATH
    if Command::new("tshark").arg("-v").stdout(Stdio::null()).status().is_ok() {
        return "tshark".to_string();
    }

    // 2. Fallback to hardcoded paths
    for path in common_paths {
        if Path::new(path).exists() { return path.to_string(); }
    }

    "tshark".to_string() // Hope for the best
}
Enter fullscreen mode Exit fullscreen mode

Challenge #2: The TShark Command Arguments

To make parsing efficient, we can't just take the default output. We need specific flags to make it machine-readable and real-time:

let mut child = Command::new(binary)
    .args([
        "-i", interface,
        "-l",           // Flush stdout after every packet (CRITICAL)
        "-n",           // Disable DNS resolution (Prevents lag)
        "-x",           // Output Hex and ASCII dump
        "-Y", filter    // Wireshark display filter 
    ])
    .stdout(Stdio::piped())
    .spawn()?;
Enter fullscreen mode Exit fullscreen mode

The Importance of -l:
Without -l, TShark buffers its output. Your Rust tool will sit silently compiling 4KB of data and then suddenly vomit 50 packets at once. For live analysis, we need line-buffering to process packets the millisecond they arrive.

Challenge #3: Parsing the Hex Dump

TShark's -x output provides a hexdump that looks like this:

0000  00 50 56 c0 00 08 00 0c 29 f0 23 11 08 00 45 00   .PV.....).#...E.
0010  00 34 1e 3c 40 00 40 06 b5 92 c0 a8 01 0a 14 4c   .4.<@.@........L
Enter fullscreen mode Exit fullscreen mode

The Rust application reads this line-by-line using BufReader. The trick is to ignore the offset (0010) and the ASCII preview on the right, extracting only the middle column to build a pure hex string like 00341e3c....

The Signature Detection (ASCII vs Wide Char)

Data on the wire is messy. Sometimes strings are sent in ASCII, but often—especially in Windows environments or .NET applications—strings are sent in UTF-16LE (Wide Char).

If you search for "BunnyName123" (ASCII), you will miss the packet if it is sent technically as B.u.n.n.y.N.a.m.e.1.2.3 (where . represents a null byte 00).

My tool generates two signatures from the config constants. Here is the math for our target "BunnyName123":

// Hex representation of "BunnyName123"
const SIG_ASCII: &str = "42756e6e794e616d65313233"; 

// Hex representation of "B.u.n.n.y.N.a.m.e.1.2.3" (Wide Char)
const SIG_WIDE: &str  = "420075006e006e0079004e0061006d0065003100320033"; 
Enter fullscreen mode Exit fullscreen mode

By converting the incoming packet to a continuous hex string, we can search for both formats simultaneously without complex decoding logic.

The Final Polish: Sanitized Logging

One requirement was to save a readable log file. Raw packets contain binary garbage and control characters that ruin text files.

I implemented a sanitizer function that converts the Hex stream back into ASCII, but filters out non-printables:

fn hex_to_clean_ascii(hex_str: &str) -> String {
    // If byte is between 32 (Space) and 126 (~), keep it.
    // Otherwise, replace with '.'
    // This turns binary soup into readable text.
}
Enter fullscreen mode Exit fullscreen mode

The Full Code

Here is the complete, single-file Rust implementation. It uses clap for argument parsing and ctrlc for graceful shutdowns.

Dependencies:

[dependencies]
clap = { version = "4.0", features = ["derive"] }
ctrlc = "3.0"
Enter fullscreen mode Exit fullscreen mode

The Source (main.rs):

use clap::Parser;
use std::fs::{File};
use std::io::{self, BufRead, BufReader, Write};
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

// ================= CONFIGURATION =================
// Target: "BunnyName123"
const SIG_ASCII: &str = "42756e6e794e616d65313233";               
// Target: "B.u.n.n.y.N.a.m.e.1.2.3" (Wide Char)
const SIG_WIDE: &str  = "420075006e006e0079004e0061006d0065003100320033";  
const TARGET_IP: &str = "192.168.1.50";                    // Replace with target
const LOG_FILENAME: &str = "captured_traffic.txt";       
// =================================================

#[derive(Parser, Debug)]
struct Args {
    #[arg(short, long)]
    interface: Option<String>,
}

fn resolve_tshark_path() -> String {
    let common_paths = [
        r"C:\Program Files\Wireshark\tshark.exe",
        r"C:\Program Files (x86)\Wireshark\tshark.exe",
        "/usr/bin/tshark",
        "/usr/local/bin/tshark",
    ];
    if Command::new("tshark").arg("-v").stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok() {
        return "tshark".to_string();
    }
    for path in common_paths {
        if Path::new(path).exists() { return path.to_string(); }
    }
    "tshark".to_string()
}

fn choose_interface(tshark_path: &str) -> String {
    println!("[*] Querying interfaces...");
    let output = match Command::new(tshark_path).arg("-D").output() {
        Ok(o) => o,
        Err(_) => panic!("Could not run tshark. Is Wireshark installed?"),
    };
    let stdout = String::from_utf8_lossy(&output.stdout);
    println!("{}", stdout);

    loop {
        print!("Select interface number (e.g. 1): ");
        io::stdout().flush().unwrap();
        let mut input = String::new();
        if io::stdin().read_line(&mut input).is_ok() {
            let trimmed = input.trim();
            if !trimmed.is_empty() { return trimmed.to_string(); }
        }
    }
}

fn main() {
    let args = Args::parse();
    let tshark_binary = resolve_tshark_path();

    let interface = match args.interface {
        Some(i) => i,
        None => choose_interface(&tshark_binary),
    };

    let mut log_file = File::create(LOG_FILENAME).expect("Failed to create log file");

    println!("=================================================");
    println!("ACTIVE MONITORING");
    println!("Filter IP:   {}", TARGET_IP);
    println!("Target Sig:  BunnyName123");
    println!("Logging to:  {}", LOG_FILENAME);
    println!("Interface:   {}", interface);
    println!("=================================================");

    let running = Arc::new(AtomicBool::new(true));
    let r = running.clone();
    ctrlc::set_handler(move || {
        println!("\nStopping...");
        r.store(false, Ordering::SeqCst);
    }).expect("Error setting Ctrl-C");

    let mut child = Command::new(&tshark_binary)
        .args([
            "-i", &interface,
            "-l", "-n", "-x",
            "-Y", &format!("ip.dst == {} || ip.src == {}", TARGET_IP, TARGET_IP)
        ])
        .stdout(Stdio::piped())
        .stderr(Stdio::inherit())
        .spawn()
        .expect("Failed to start tshark");

    let stdout = child.stdout.take().expect("Failed to capture stdout");
    let reader = BufReader::new(stdout);

    let mut raw_packet_lines: Vec<String> = Vec::new();
    let mut searchable_hex = String::new();
    let mut packet_count = 0u64;

    for line in reader.lines() {
        if !running.load(Ordering::SeqCst) { break; }

        if let Ok(text) = line {
            let trimmed = text.trim();

            if trimmed.is_empty() || text.starts_with("Frame ") {
                if !raw_packet_lines.is_empty() {
                    process_packet(
                        &mut packet_count,
                        &searchable_hex,
                        &mut log_file
                    );
                    raw_packet_lines.clear();
                    searchable_hex.clear();
                }
            }

            if !trimmed.is_empty() {
                raw_packet_lines.push(text.clone());
                if is_hex_dump_line(&text) {
                    searchable_hex.push_str(&extract_hex_bytes(&text));
                }
            }
        }
    }
    let _ = child.kill();
    println!("\n[Done] Total packets captured: {}", packet_count);
}

fn process_packet(count: &mut u64, clean_hex: &str, file: &mut File) {
    *count += 1;

    // Convert to ASCII for logging
    let ascii_content = hex_to_clean_ascii(clean_hex);
    if !ascii_content.is_empty() {
        let _ = writeln!(file, "{}", ascii_content);
    }

    // Real-time alerts
    let found_ascii = clean_hex.contains(SIG_ASCII);
    let found_wide = clean_hex.contains(SIG_WIDE);

    if found_ascii || found_wide {
        println!("\n[!!!] TARGET FOUND in Packet #{}", count);
        if found_ascii { println!("      -> Matched ASCII: BunnyName123"); }
        if found_wide  { println!("      -> Matched WIDE:  B.u.n.n.y.N.a.m.e..."); }
    }
}

fn is_hex_dump_line(line: &str) -> bool {
    if line.len() < 6 { return false; }
    let start = &line[..4];
    if !start.chars().all(|c| c.is_ascii_hexdigit()) { return false; }
    true
}

fn extract_hex_bytes(line: &str) -> String {
    let mut hex_string = String::new();
    let end_idx = if line.len() > 54 { 54 } else { line.len() };
    let start_idx = if line.len() > 6 { 6 } else { 0 };
    if start_idx >= end_idx { return String::new(); }

    let sub = &line[start_idx..end_idx];
    for c in sub.chars() {
        if c.is_ascii_hexdigit() {
            hex_string.push(c.to_ascii_lowercase());
        }
    }
    hex_string
}

fn hex_to_clean_ascii(hex_str: &str) -> String {
    let mut ascii = String::with_capacity(hex_str.len() / 2);
    let chars: Vec<char> = hex_str.chars().collect();

    for chunk in chars.chunks(2) {
        if chunk.len() == 2 {
            let s: String = chunk.iter().collect();
            if let Ok(byte) = u8::from_str_radix(&s, 16) {
                if byte >= 32 && byte <= 126 {
                    ascii.push(byte as char);
                } else {
                    ascii.push('.');
                }
            }
        }
    }
    ascii
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

This project demonstrates the power of Rust as "Glue Code." We didn't need to handle complex C++ pointers or write kernel drivers. We leveraged the industry-standard tool (Wireshark) and wrapped it in Rust's memory safety, powerful string manipulation primitives, and concurrency models.

The result is a tool that runs anywhere, captures live traffic, and alerts us immediately when our bunny hops onto the network.

Top comments (0)