Security & Networking

EchoTrap

A TCP honeypot that resists Masscan and ZMap fingerprinting via protocol emulation and kernel-level port migration.

RustTokioTCPnftablesPrometheusDocker

36k

Connections/sec

106µs

Migration Latency

3

Protocol Personas

10

Integration Tests

01

Problem

Problem statement, constraint shape, and the gap this project explores.

Problem Statement

How do you build a honeypot that resists automated fingerprinting from tools like Masscan and ZMap?

Challenge

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.

Why Existing Approaches Failed

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.

02

Constraints

Non-negotiable boundaries that shaped the implementation.

Fingerprint resistance

No fixed-latency banners, no RST on close, no generic echo

Migration latency

Minimize time between scan detection and new listener ready

Memory bound

LRU-capped attack tracker regardless of scan volume

Zero downtime

No dropped connections during port migration

Observability

Prometheus + structured JSON logs without blocking the hot path

03

Architecture

The primary design surface: flow, subsystem roles, and state boundaries.

Architecture Brief

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.

Execution Flow
01

Inbound TCP

02

Accept loop

03

Detector.record_and_check

04

[if threshold breached] Migration executor

05

new listener spawned

06

nftables REDIRECT installed (Linux)

07

decoy takes over after hold period

01

Persona Layer

Per-protocol connection handlers (SSH, HTTP, Redis, Raw) with banner, timing jitter, and socket option profiles matched to real services.

02

Attack Detector

LruCache<IpAddr, Vec<Instant>> sliding-window tracker, hard-capped at 10k IPs (~720KB worst case).

03

Migration Engine

Safe port selection (excludes ephemeral + privileged ranges), probe-bind verification, decoy listener with persona-consistent banner.

04

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.

05

Dashboard

Axum HTTP server exposing /metrics (JSON), /metrics/prometheus, /status, /health.

06

Dual Logger

HERALD-styled terminal output for operators + NDJSON file output for SIEM ingestion (Elastic/Splunk/Loki).

04

Engineering Tradeoffs

Design review notes: what was optimized and what was deliberately left behind.

EDR-01
Decision

nftables REDIRECT vs. accept-loss-on-migration

Why Chosen

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.

Alternative Rejected

Cross-platform uniformity — Windows/macOS builds use the simpler decoy mechanism

Impact

Kernel-level REDIRECT rule on Linux, with automatic fallback to a 200ms decoy-only window on other platforms

EDR-02
Decision

LruCache over unbounded HashMap for attack tracking

Why Chosen

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.

Alternative Rejected

Perfect historical recall of every scanning IP

Impact

lru crate with a hard 10k-entry cap

EDR-03
Decision

thiserror structured errors over Box<dyn Error>

Why Chosen

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.

Alternative Rejected

Slightly more boilerplate per error site

Impact

Named error enums (DashboardError, MigrationError, SockoptError) with source chaining

EDR-04
Decision

Per-persona socket options via socket2 pre-bind

Why Chosen

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.

Alternative Rejected

Simplicity of tokio's default TcpListener::bind

Impact

Manual TCP_NODELAY / SO_KEEPALIVE / recv-buffer tuning matched to each emulated service

05

Failure Modes

Incident-style notes for the ways the design can break.

Connection flood beyond max-connections

FM-01
Impact

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.

Mitigation

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-02
Impact

add_redirect() fails non-fatally; falls back to the decoy-only migration path with a 200ms settle window.

Mitigation

add_redirect() fails non-fatally; falls back to the decoy-only migration path with a 200ms settle window.

Port bind race during migration

FM-03
Impact

find_free_port probe-binds before committing; the new listener is confirmed accepting before the old one receives its shutdown signal.

Mitigation

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-04
Impact

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).

Mitigation

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).

06

Benchmarks

Environment first, numbers second. Metrics should be inspectable, not ornamental.

Test Environment
OS

WSL2, kernel 6.6 (Ubuntu)

Runtime

Tokio, release build

Benchmark tool

criterion

Test suite

10 integration tests (personas, dashboard, Prometheus format, port safety, config validation)

CI

GitHub Actions — fmt, clippy -D warnings, test, Docker smoke test

Performance Results
Connection Throughput (1k)

36,441conn/s

Migration Latency

106µs

Detector Overhead (single IP)

59ns/call

Detector Overhead (1k IPs, LRU)

70ns/call

Attack Tracker Memory Cap

720KB max

07

Lessons Learned

Engineering takeaways from the implementation, including remaining work.

01

Fixed-latency responses are the single most exploitable honeypot signal

per-protocol jitter matters more than banner content.

02

tokio::net::TcpListener doesn't expose pre-bind socket options; matching a real service's TCP fingerprint requires socket2 and manual bind/listen sequencing.

03

nftables REDIRECT on loopback requires net.ipv4.conf.lo.route_localnet=1

undocumented in most nftables guides, found only through direct testing.

04

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>.

Akshat