Learn in Public unlocks on Jan 1, 2026

This lesson will be public then. Admins can unlock early with a password.

Build a Basic Vulnerability Scanner in Rust (2026 Edition)
Learn Cybersecurity

Build a Basic Vulnerability Scanner in Rust (2026 Edition)

Create a simple Rust vulnerability scanner with async networking, fingerprinting modules, and JSON exports—plus how defenders catch scans.

rust vulnerability scanning fingerprinting async rust blue team

Build a minimal, ethical Rust scanner to understand how recon works and how defenders spot it. Everything runs locally against a mock target with validation and cleanup.

What You’ll Build

  • A local mock HTTP target on ports 8000 and 8001.
  • An async Rust scanner that checks ports, grabs HTTP banners, and logs NDJSON.
  • Safety controls: concurrency caps, delays, and clear User-Agent tagging.

Prerequisites

  • macOS or Linux with Rust 1.80+ (rustc --version).
  • Python 3.10+ for the mock target.
  • No external targets—stay on localhost for this lab.
  • Scan only assets you own or are explicitly authorized to test.
  • Keep concurrency low; stop immediately on 429/403 or abuse complaints.
  • Identify your scanner via User-Agent and keep audit logs.

Step 1) Launch a safe mock target

Click to view commands
mkdir -p mock_http/{siteA,siteB}
echo "hello A" > mock_http/siteA/index.html
echo "hello B" > mock_http/siteB/index.html
python3 -m http.server 8000 --directory mock_http/siteA > mock_http/siteA.log 2>&1 &
python3 -m http.server 8001 --directory mock_http/siteB > mock_http/siteB.log 2>&1 &
Validation: `curl -I http://127.0.0.1:8000/` and `curl -I http://127.0.0.1:8001/` should return `200 OK`.

Common fix: If ports are in use, pick free ports (e.g., 9000/9001) and reuse them below.

Step 2) Scaffold the Rust project

Click to view commands
cargo new rust-vulnscan
cd rust-vulnscan
Validation: `ls` shows `Cargo.toml` and `src/main.rs`.

Step 3) Add dependencies

Replace Cargo.toml with:

Click to view toml code
[package]
name = "rust-vulnscan"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1.40", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
clap = { version = "4.5", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
futures = "0.3"
indicatif = "0.17"
chrono = { version = "0.4", features = ["serde"] }
anyhow = "1.0"
Validation: `cargo check` should pass after adding code in the next step.

Step 4) Implement the scanner

Replace src/main.rs with:

Click to view Rust code
use clap::Parser;
use chrono::Utc;
use futures::stream::{self, StreamExt};
use indicatif::{ProgressBar, ProgressStyle};
use reqwest::{Client, StatusCode};
use serde::Serialize;
use std::{fs::File, io::Write, net::SocketAddr, time::Duration};
use tokio::net::TcpStream;
use tokio::time::sleep;

#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Args {
    /// Target host (e.g., 127.0.0.1)
    #[arg(long)]
    host: String,
    /// Ports to scan (comma-separated)
    #[arg(long, default_value = "8000,8001,2222")]
    ports: String,
    /// Max concurrent tasks
    #[arg(long, default_value_t = 10)]
    concurrency: usize,
    /// Delay in ms between tasks
    #[arg(long, default_value_t = 50)]
    delay_ms: u64,
    /// Output file (NDJSON)
    #[arg(long, default_value = "results.ndjson")]
    out: String,
}

#[derive(Serialize)]
struct Finding {
    host: String,
    port: u16,
    open: bool,
    service: String,
    status: Option<u16>,
    banner: Option<String>,
    timestamp: String,
}

async fn tcp_is_open(host: &str, port: u16, timeout_ms: u64) -> bool {
    let addr: SocketAddr = format!("{}:{}", host, port).parse().unwrap();
    tokio::time::timeout(Duration::from_millis(timeout_ms), TcpStream::connect(addr))
        .await
        .is_ok()
}

async fn http_fingerprint(client: &Client, host: &str, port: u16) -> (Option<u16>, Option<String>) {
    let url = format!("http://{}:{}/", host, port);
    match client.get(&url).send().await {
        Ok(resp) => {
            let status = resp.status().as_u16();
            let banner = resp
                .headers()
                .get(reqwest::header::SERVER)
                .and_then(|v| v.to_str().ok())
                .map(|s| s.to_string());
            (Some(status), banner)
        }
        Err(_) => (None, None),
    }
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let args = Args::parse();
    let ports: Vec<u16> = args
        .ports
        .split(',')
        .filter_map(|p| p.trim().parse().ok())
        .collect();

    let client = Client::builder()
        .user_agent("rust-vulnscan (+you@example.com)")
        .timeout(Duration::from_secs(5))
        .build()?;

    let pb = ProgressBar::new(ports.len() as u64);
    pb.set_style(
        ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] {pos}/{len} {msg}")?
            .progress_chars("#>-"),
    );

    let mut out = File::create(&args.out)?;

    stream::iter(ports)
        .map(|port| {
            let host = args.host.clone();
            let client = client.clone();
            let delay = args.delay_ms;
            async move {
                let open = tcp_is_open(&host, port, 1200).await;
                let (status, banner) = if open {
                    http_fingerprint(&client, &host, port).await
                } else {
                    (None, None)
                };

                let finding = Finding {
                    host: host.clone(),
                    port,
                    open,
                    service: if open { "tcp".into() } else { "closed".into() },
                    status,
                    banner,
                    timestamp: Utc::now().to_rfc3339(),
                };

                sleep(Duration::from_millis(delay)).await;
                finding
            }
        })
        .buffer_unordered(args.concurrency)
        .for_each(|finding| {
            pb.inc(1);
            let line = serde_json::to_string(&finding).unwrap();
            println!("{}", line);
            writeln!(out, "{}", line).unwrap();
            futures::future::ready(())
        })
        .await;

    pb.finish_with_message("done");
    Ok(())
}
Validation:
Click to view commands
cargo check
cargo run -- --host 127.0.0.1 --ports 8000,8001,2222 --concurrency 5 --delay-ms 25
Expected: Ports 8000/8001 show `open` with HTTP status 200; port 2222 shows `open: false`.

Common fixes:

  • Address already in use: change mock target ports and update --ports.
  • TLS errors: we use HTTP only; ensure URLs are http://.

Step 5) Understand defender signals

  • Multiple rapid banner grabs on adjacent ports look like scanners—keep concurrency low.
  • Consistent User-Agent strings reveal tooling; be transparent and provide contact info.
  • Sudden spikes in connection attempts trigger rate limits; back off when you see 429/403.

Cleanup

Click to view commands
cd ..
pkill -f "http.server 8000" || true
pkill -f "http.server 8001" || true
rm -rf rust-vulnscan mock_http
Validation: `lsof -i :8000` and `:8001` should show no listeners.

Quick Reference

  • Stay in authorized scope; run locally for practice.
  • Cap concurrency and add delays to avoid noisy scans.
  • Log NDJSON for every probe with timestamps and statuses.
  • Be identifiable (User-Agent) and stop on rate-limit signals.

Similar Topics

FAQs

Can I use these labs in production?

No—treat them as educational. Adapt, review, and security-test before any production use.

How should I follow the lessons?

Start from the Learn page order or use Previous/Next on each lesson; both flow consistently.

What if I lack test data or infra?

Use synthetic data and local/lab environments. Never target networks or data you don't own or have written permission to test.

Can I share these materials?

Yes, with attribution and respecting any licensing for referenced tools or datasets.