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
Learn Cybersecurity

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.

fingerprinting rust recon headers tls

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.
  • 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 &
Validation: `curl -I http://127.0.0.1:8010/` should show `Server: demo-web/1.2`.

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
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-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"
Validation: `cargo check` should succeed after adding the code.

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(())
}
Validation:
Click to view commands
cargo run -- --url http://127.0.0.1:8010/
Expected JSON fields: `status: 200`, `server: "demo-web/1.2"`, `powered_by: "ExampleCMS 3.2"`, `meta_generator: "ExampleCMS 3.2"`, `title: "Demo CMS"`.

Common fixes:

  • connection refused: ensure mock_site/server.py is running and the URL/port match.
  • HTML parsing empty: confirm the page has a <title> and meta name="generator">.

Step 5) Reduce your own fingerprint (defense)

  • Strip or generalize Server/X-Powered-By headers at your reverse proxy.
  • Remove meta generator tags 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
Validation: `lsof -i :8010` should show no listener; project folder removed.

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.

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.

\n \n

Hello from the demo site

\n\nHTML\n\ncat > mock_site/server.py <<'PY'\nfrom http.server import SimpleHTTPRequestHandler, HTTPServer\nclass Handler(SimpleHTTPRequestHandler):\n def end_headers(self):\n self.send_header(\"Server\", \"demo-web/1.2\")\n self.send_header(\"X-Powered-By\", \"ExampleCMS 3.2\")\n super().end_headers()\n\nif __name__ == \"__main__\":\n HTTPServer((\"0.0.0.0\", 8010), Handler).serve_forever()\nPY\n\npython3 mock_site/server.py > mock_site/server.log 2>&1 &\n```\n\n\nValidation: `curl -I http://127.0.0.1:8010/` should show `Server: demo-web/1.2`.\n\nCommon fix: If port 8010 is busy, change to a free port in both the server and scanner commands.\n\n## Step 2) Create the Rust project\n
\nClick to view commands\n\n```bash\ncargo new rust-fp\ncd rust-fp\n```\n\n
\nValidation: `ls` shows `Cargo.toml` and `src/main.rs`.\n\n## Step 3) Add dependencies\nReplace `Cargo.toml` with:\n
\nClick to view toml code\n\n```toml\n[package]\nname = \"rust-fp\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\ntokio = { version = \"1.40\", features = [\"full\"] }\nreqwest = { version = \"0.12\", features = [\"rustls-tls\"] }\nscraper = \"0.19\"\nclap = { version = \"4.5\", features = [\"derive\"] }\nserde = { version = \"1.0\", features = [\"derive\"] }\nserde_json = \"1.0\"\nchrono = { version = \"0.4\", features = [\"serde\"] }\nanyhow = \"1.0\"\n```\n\n
\nValidation: `cargo check` should succeed after adding the code.\n\n## Step 4) Implement the fingerprinting CLI\nReplace `src/main.rs` with:\n
\nClick to view Rust code\n\n```rust\nuse clap::Parser;\nuse chrono::Utc;\nuse reqwest::Client;\nuse scraper::{Html, Selector};\nuse serde::Serialize;\nuse std::time::Duration;\n\n#[derive(Parser, Debug)]\n#[command(author, version, about)]\nstruct Args {\n /// Target URL (http://127.0.0.1:8010/)\n #[arg(long)]\n url: String,\n /// Request timeout seconds\n #[arg(long, default_value_t = 5)]\n timeout: u64,\n}\n\n#[derive(Serialize)]\nstruct Fingerprint {\n url: String,\n status: Option,\n server: Option,\n powered_by: Option,\n meta_generator: Option,\n title: Option,\n timestamp: String,\n}\n\n#[tokio::main]\nasync fn main() -> anyhow::Result<()> {\n let args = Args::parse();\n let client = Client::builder()\n .user_agent(\"rust-fp (+you@example.com)\")\n .timeout(Duration::from_secs(args.timeout))\n .build()?;\n\n let resp = client.get(&args.url).send().await;\n let (status, headers, body) = match resp {\n Ok(r) => {\n let status = Some(r.status().as_u16());\n let headers = r.headers().clone();\n let body = r.text().await.unwrap_or_default();\n (status, headers, body)\n }\n Err(_) => (None, reqwest::header::HeaderMap::new(), String::new()),\n };\n\n let doc = Html::parse_document(&body);\n let title_sel = Selector::parse(\"title\").unwrap();\n let meta_gen_sel = Selector::parse(\"meta[name=\\\"generator\\\"]\").unwrap();\n\n let title = doc\n .select(&title_sel)\n .next()\n .and_then(|n| Some(n.text().collect::().trim().to_string()))\n .filter(|s| !s.is_empty());\n\n let meta_generator = doc\n .select(&meta_gen_sel)\n .next()\n .and_then(|n| n.value().attr(\"content\"))\n .map(|s| s.to_string());\n\n let server = headers\n .get(reqwest::header::SERVER)\n .and_then(|v| v.to_str().ok())\n .map(|s| s.to_string());\n\n let powered_by = headers\n .get(\"x-powered-by\")\n .and_then(|v| v.to_str().ok())\n .map(|s| s.to_string());\n\n let fp = Fingerprint {\n url: args.url,\n status,\n server,\n powered_by,\n meta_generator,\n title,\n timestamp: Utc::now().to_rfc3339(),\n };\n\n println!(\"{}\", serde_json::to_string_pretty(&fp)?);\n Ok(())\n}\n```\n\n
\nValidation:\n
\nClick to view commands\n\n```bash\ncargo run -- --url http://127.0.0.1:8010/\n```\n\n
\nExpected JSON fields: `status: 200`, `server: \"demo-web/1.2\"`, `powered_by: \"ExampleCMS 3.2\"`, `meta_generator: \"ExampleCMS 3.2\"`, `title: \"Demo CMS\"`.\n\nCommon fixes:\n- `connection refused`: ensure `mock_site/server.py` is running and the URL/port match.\n- HTML parsing empty: confirm the page has a `` and `meta name=\"generator\">`.\n\n## Step 5) Reduce your own fingerprint (defense)\n- Strip or generalize `Server`/`X-Powered-By` headers at your reverse proxy.\n- Remove `meta generator` tags in production builds.\n- Serve assets from a CDN/WAF that normalizes headers and TLS configs.\n- Avoid exposing internal hostnames in TLS SANs; rotate certs when names change.\n\n## Cleanup\n<details>\n<summary>Click to view commands</summary>\n\n```bash\ncd ..\npkill -f \"mock_site/server.py\" || true\nrm -rf rust-fp mock_site\n```\n\n</details>\nValidation: `lsof -i :8010` should show no listener; project folder removed.\n\n## Quick Reference\n- Collect headers + HTML meta to guess stacks; keep timeouts tight.\n- Tag your UA and keep logs—transparency reduces abuse flags.\n- Defend by minimizing banners, normalizing responses, and hiding generator tags." }</script> </main> <footer class="bg-gray-900 border-t border-cyan-500/20 mt-auto"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <div class="grid grid-cols-1 md:grid-cols-4 gap-8"> <div class="col-span-1 md:col-span-2"> <div class="flex items-center space-x-2 mb-4"> <div class="w-8 h-8 bg-gradient-to-br from-cyan-400 to-blue-600 rounded flex items-center justify-center"> <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path> </svg> </div> <span class="text-xl font-bold text-white">CyberSec</span> </div> <p class="text-gray-200 text-sm"> Your trusted source for cybersecurity insights, threat analysis, and best practices. Stay secure, stay informed. </p> </div> <div> <h3 class="text-white font-semibold mb-4">Quick Links</h3> <ul class="space-y-2"> <li> <a href="/" class="text-gray-200 hover:text-cyan-300 transition-colors text-sm"> Home </a> </li> <li> <a href="/blog" class="text-gray-200 hover:text-cyan-300 transition-colors text-sm"> Blog </a> </li> <li> <a href="/about" class="text-gray-200 hover:text-cyan-300 transition-colors text-sm"> About </a> </li> <li> <a href="/contact" class="text-gray-200 hover:text-cyan-300 transition-colors text-sm"> Contact </a> </li> </ul> </div> <div> <h3 class="text-white font-semibold mb-4">Resources</h3> <ul class="space-y-2"> <li> <a href="/blog" class="text-gray-200 hover:text-cyan-300 transition-colors text-sm"> Security Guides </a> </li> <li> <a href="/blog" class="text-gray-200 hover:text-cyan-300 transition-colors text-sm"> Threat Reports </a> </li> <li> <a href="/blog" class="text-gray-200 hover:text-cyan-300 transition-colors text-sm"> Best Practices </a> </li> </ul> </div> </div> <div class="mt-8 pt-8 border-t border-cyan-500/20"> <div class="flex flex-col md:flex-row justify-between items-center"> <p class="text-gray-300 text-sm"> © 2025 Cybersecurity Blog. All rights reserved. </p> <div class="flex space-x-6 mt-4 md:mt-0"> <a href="/terms-of-service" class="text-gray-200 hover:text-cyan-300 transition-colors text-sm"> Terms of Service </a> <a href="/sitemap.xml" class="text-gray-200 hover:text-cyan-300 transition-colors text-sm"> Sitemap </a> <a href="/rss.xml" class="text-gray-200 hover:text-cyan-300 transition-colors text-sm"> RSS </a> </div> </div> </div> </div> </footer> </body></html>