EchoTrap
A TCP honeypot that resists Masscan and ZMap fingerprinting via protocol emulation and kernel-level port migration.
36k
Connections/sec
106µs
Migration Latency
3
Protocol Personas
10
Integration Tests
Problem
Problem statement, constraint shape, and the gap this project explores.
How do you build a honeypot that resists automated fingerprinting from tools like Masscan and ZMap?
Most honeypots are identified by scanners in under a second — banner timing, TCP window size, and echo behavior all give them away. EchoTrap addresses all three: protocol-accurate personas (SSH/HTTP/Redis), TCP socket options matched to real server fingerprints, and nftables-based zero-downtime port migration when a scan is detected.
Scanners identify honeypots by probing for exactly three signals: fixed-latency banner responses, default OS socket fingerprints that don't match the claimed service, and dumb echo behavior instead of real protocol semantics. EchoTrap was built to eliminate all three — emulating real OpenSSH/nginx/Redis banners with per-protocol timing jitter, configuring TCP socket options (SO_KEEPALIVE, TCP_NODELAY, recv buffer) to match Ubuntu 22.04 server defaults per persona, and migrating to a new port the moment a scan pattern is detected, using nftables to redirect traffic transparently so no connections are dropped.
Constraints
Non-negotiable boundaries that shaped the implementation.
No fixed-latency banners, no RST on close, no generic echo
Minimize time between scan detection and new listener ready
LRU-capped attack tracker regardless of scan volume
No dropped connections during port migration
Prometheus + structured JSON logs without blocking the hot path
Architecture
The primary design surface: flow, subsystem roles, and state boundaries.
EchoTrap runs on Tokio, with a sliding-window attack detector tracking per-IP connection rates in an LRU-bounded cache. On threshold breach, a migration executor selects a safe port (avoiding the Linux ephemeral range), spawns a new listener, and — on Linux — installs an nftables REDIRECT rule so traffic to the old port is forwarded at the kernel level while a decoy listener takes over after a hold period.
Inbound TCP
Accept loop
Detector.record_and_check
[if threshold breached] Migration executor
new listener spawned
nftables REDIRECT installed (Linux)
decoy takes over after hold period
Persona Layer
Per-protocol connection handlers (SSH, HTTP, Redis, Raw) with banner, timing jitter, and socket option profiles matched to real services.
Attack Detector
LruCache<IpAddr, Vec<Instant>> sliding-window tracker, hard-capped at 10k IPs (~720KB worst case).
Migration Engine
Safe port selection (excludes ephemeral + privileged ranges), probe-bind verification, decoy listener with persona-consistent banner.
nftables Redirect
Kernel-level REDIRECT rule installed on migration — old port traffic forwarded to new port with zero dropped connections; falls back to decoy-only on non-Linux.
Dashboard
Axum HTTP server exposing /metrics (JSON), /metrics/prometheus, /status, /health.
Dual Logger
HERALD-styled terminal output for operators + NDJSON file output for SIEM ingestion (Elastic/Splunk/Loki).
Engineering Tradeoffs
Design review notes: what was optimized and what was deliberately left behind.
nftables REDIRECT vs. accept-loss-on-migration
REDIRECT eliminates the gap between old listener shutdown and new listener ready entirely, at the cost of requiring CAP_NET_ADMIN and nft in PATH. The fallback keeps the binary functional everywhere.
Cross-platform uniformity — Windows/macOS builds use the simpler decoy mechanism
Kernel-level REDIRECT rule on Linux, with automatic fallback to a 200ms decoy-only window on other platforms
LruCache over unbounded HashMap for attack tracking
An unbounded HashMap<IpAddr, Vec<Instant>> grows without limit under a sustained Masscan flood. LRU eviction bounds memory at ~720KB worst case regardless of scan volume.
Perfect historical recall of every scanning IP
lru crate with a hard 10k-entry cap
thiserror structured errors over Box<dyn Error>
Box<dyn Error> erases the failure type at the call site, making it impossible for callers to distinguish a bind failure from a port exhaustion. Structured variants let network.rs log precisely what failed and why.
Slightly more boilerplate per error site
Named error enums (DashboardError, MigrationError, SockoptError) with source chaining
Per-persona socket options via socket2 pre-bind
tokio's bind doesn't expose socket options before listen(), and OS defaults don't match what a real OpenSSH or nginx server presents — a scanner checking TCP fingerprints alongside the banner would catch the mismatch.
Simplicity of tokio's default TcpListener::bind
Manual TCP_NODELAY / SO_KEEPALIVE / recv-buffer tuning matched to each emulated service
Failure Modes
Incident-style notes for the ways the design can break.
Connection flood beyond max-connections
FM-01Semaphore-based cap (default 10k) rejects excess connections with a graceful FIN — not RST, which is itself a honeypot signal — after a 500ms backpressure window.
Semaphore-based cap (default 10k) rejects excess connections with a graceful FIN — not RST, which is itself a honeypot signal — after a 500ms backpressure window.
nft unavailable or permission denied
FM-02add_redirect() fails non-fatally; falls back to the decoy-only migration path with a 200ms settle window.
add_redirect() fails non-fatally; falls back to the decoy-only migration path with a 200ms settle window.
Port bind race during migration
FM-03find_free_port probe-binds before committing; the new listener is confirmed accepting before the old one receives its shutdown signal.
find_free_port probe-binds before committing; the new listener is confirmed accepting before the old one receives its shutdown signal.
Ungraceful process termination
FM-04Ctrl-C triggers a 5-second drain window for in-flight connections before exit; abrupt kill can leave a bound socket on Windows (no SO_REUSEADDR equivalent issue on Linux).
Ctrl-C triggers a 5-second drain window for in-flight connections before exit; abrupt kill can leave a bound socket on Windows (no SO_REUSEADDR equivalent issue on Linux).
Benchmarks
Environment first, numbers second. Metrics should be inspectable, not ornamental.
WSL2, kernel 6.6 (Ubuntu)
Tokio, release build
criterion
10 integration tests (personas, dashboard, Prometheus format, port safety, config validation)
GitHub Actions — fmt, clippy -D warnings, test, Docker smoke test
36,441conn/s
106µs
59ns/call
70ns/call
720KB max
Lessons Learned
Engineering takeaways from the implementation, including remaining work.
Fixed-latency responses are the single most exploitable honeypot signal
per-protocol jitter matters more than banner content.
tokio::net::TcpListener doesn't expose pre-bind socket options; matching a real service's TCP fingerprint requires socket2 and manual bind/listen sequencing.
nftables REDIRECT on loopback requires net.ipv4.conf.lo.route_localnet=1
undocumented in most nftables guides, found only through direct testing.
Structured error types pay off immediately during multi-platform development
the Linux/Windows fallback logic in the migration path would have been unreadable with Box<dyn Error>.