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)
Create a simple Rust vulnerability scanner with async networking, fingerprinting modules, and JSON exports—plus how defenders catch scans.
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.
Safety and Legal
- 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 &
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
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"
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(())
}
Click to view commands
cargo check
cargo run -- --host 127.0.0.1 --ports 8000,8001,2222 --concurrency 5 --delay-ms 25
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
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.