Learn in Public unlocks on Jan 1, 2026
This lesson will be public then. Admins can unlock early with a password.
Build Your Own Web Fingerprinting Tool in Rust
Create a Rust tool that detects CMS/tech stacks via headers, responses, and TLS hints—plus how to reduce exposure.
Fingerprint responsibly: gather headers and HTML hints, log them, and learn how to minimize your own exposure. This lab is fully local, with validation and cleanup.
What You’ll Build
- A mock website with custom headers and HTML meta tags.
- A Rust fingerprinting CLI that fetches a page, extracts headers/meta, and emits JSON.
- Safety controls (timeouts, UA tagging) and remediation tips.
Prerequisites
- macOS or Linux with Rust 1.80+.
- Python 3.10+.
- Run locally only unless you have written permission for a target.
Safety and Legal
- Fingerprint only systems you own/are authorized to test.
- Keep concurrency low and add delays; avoid hitting login or admin pages without consent.
- Strip sensitive data from logs if working on real targets.
Step 1) Start a mock site with obvious fingerprints
Click to view commands
mkdir -p mock_site
cat > mock_site/index.html <<'HTML'
<html>
<head>
<title>Demo CMS</title>
<meta name="generator" content="ExampleCMS 3.2">
<script src="/static/jquery-3.7.0.js"></script>
</head>
<body><h1>Hello from the demo site</h1></body>
</html>
HTML
cat > mock_site/server.py <<'PY'
from http.server import SimpleHTTPRequestHandler, HTTPServer
class Handler(SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header("Server", "demo-web/1.2")
self.send_header("X-Powered-By", "ExampleCMS 3.2")
super().end_headers()
if __name__ == "__main__":
HTTPServer(("0.0.0.0", 8010), Handler).serve_forever()
PY
python3 mock_site/server.py > mock_site/server.log 2>&1 &
Common fix: If port 8010 is busy, change to a free port in both the server and scanner commands.
Step 2) Create the Rust project
Click to view commands
cargo new rust-fp
cd rust-fp
Step 3) Add dependencies
Replace Cargo.toml with:
Click to view toml code
[package]
name = "rust-fp"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1.40", features = ["full"] }
reqwest = { version = "0.12", features = ["rustls-tls"] }
scraper = "0.19"
clap = { version = "4.5", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
anyhow = "1.0"
Step 4) Implement the fingerprinting CLI
Replace src/main.rs with:
Click to view Rust code
use clap::Parser;
use chrono::Utc;
use reqwest::Client;
use scraper::{Html, Selector};
use serde::Serialize;
use std::time::Duration;
#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Args {
/// Target URL (http://127.0.0.1:8010/)
#[arg(long)]
url: String,
/// Request timeout seconds
#[arg(long, default_value_t = 5)]
timeout: u64,
}
#[derive(Serialize)]
struct Fingerprint {
url: String,
status: Option<u16>,
server: Option<String>,
powered_by: Option<String>,
meta_generator: Option<String>,
title: Option<String>,
timestamp: String,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
let client = Client::builder()
.user_agent("rust-fp (+you@example.com)")
.timeout(Duration::from_secs(args.timeout))
.build()?;
let resp = client.get(&args.url).send().await;
let (status, headers, body) = match resp {
Ok(r) => {
let status = Some(r.status().as_u16());
let headers = r.headers().clone();
let body = r.text().await.unwrap_or_default();
(status, headers, body)
}
Err(_) => (None, reqwest::header::HeaderMap::new(), String::new()),
};
let doc = Html::parse_document(&body);
let title_sel = Selector::parse("title").unwrap();
let meta_gen_sel = Selector::parse("meta[name=\"generator\"]").unwrap();
let title = doc
.select(&title_sel)
.next()
.and_then(|n| Some(n.text().collect::<String>().trim().to_string()))
.filter(|s| !s.is_empty());
let meta_generator = doc
.select(&meta_gen_sel)
.next()
.and_then(|n| n.value().attr("content"))
.map(|s| s.to_string());
let server = headers
.get(reqwest::header::SERVER)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let powered_by = headers
.get("x-powered-by")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let fp = Fingerprint {
url: args.url,
status,
server,
powered_by,
meta_generator,
title,
timestamp: Utc::now().to_rfc3339(),
};
println!("{}", serde_json::to_string_pretty(&fp)?);
Ok(())
}
Click to view commands
cargo run -- --url http://127.0.0.1:8010/
Common fixes:
connection refused: ensuremock_site/server.pyis running and the URL/port match.- HTML parsing empty: confirm the page has a
<title>andmeta name="generator">.
Step 5) Reduce your own fingerprint (defense)
- Strip or generalize
Server/X-Powered-Byheaders at your reverse proxy. - Remove
meta generatortags in production builds. - Serve assets from a CDN/WAF that normalizes headers and TLS configs.
- Avoid exposing internal hostnames in TLS SANs; rotate certs when names change.
Cleanup
Click to view commands
cd ..
pkill -f "mock_site/server.py" || true
rm -rf rust-fp mock_site
Quick Reference
- Collect headers + HTML meta to guess stacks; keep timeouts tight.
- Tag your UA and keep logs—transparency reduces abuse flags.
- Defend by minimizing banners, normalizing responses, and hiding generator tags.