# portspoof-py A Python asyncio rewrite of [portspoof](https://github.com/drk1wi/portspoof) — a TCP honeypot that makes every port on a machine appear open and running a real service. When a port scanner connects to any TCP port, it receives a plausible fake service banner (SSH, HTTP, FTP, etc.). The banners are randomised from a signature library at startup, so the scanner cannot fingerprint the deception from banner patterns alone. --- ## Table of contents 1. [How it works](#how-it-works) 2. [Requirements](#requirements) 3. [Installation](#installation) 4. [Quick start](#quick-start) 5. [CLI reference](#cli-reference) 6. [Signature file](#signature-file) 7. [Config file](#config-file) 8. [iptables rules](#iptables-rules) 9. [JSON log](#json-log) 10. [Web admin interface](#web-admin-interface) 11. [Email alerts](#email-alerts) 12. [systemd service](#systemd-service) 13. [Recipes](#recipes) 14. [Troubleshooting](#troubleshooting) --- ## How it works ``` attacker kernel portspoof-py │ │ │ │─── TCP SYN → port 443 ──►│ │ │ │ iptables REDIRECT │ │ │──► port 4444 ────────────►│ │◄── TCP SYN-ACK ──────────│◄──────────────────────────│ │─── data ────────────────►│──────────────────────────►│ │ │ SO_ORIGINAL_DST │ │ │ recovers port 443 │ │◄── fake HTTPS banner ────│◄──────────────────────────│ │─── FIN ─────────────────►│──────────────────────────►│ ``` 1. **iptables PREROUTING REDIRECT** — on startup, three rules are added to the `nat` table. All incoming TCP traffic (except port 22 and the listener port itself) is transparently redirected to the single listener port. 2. **Single asyncio listener** — one coroutine handles every connection. There is no thread pool and no polling loop. 3. **SO_ORIGINAL_DST** — the Linux kernel preserves the originally-intended destination port in the socket. The handler reads it with `getsockopt(SOL_IP, SO_ORIGINAL_DST)`. 4. **Pre-computed banner map** — at startup, each of the 65,536 ports is assigned a banner generated by processing a randomly-chosen regex pattern from the signature file. Banner generation is done once; serving is instant. 5. **Graceful shutdown** — SIGTERM/SIGINT triggers the stop event, the server drains, the log queue is flushed, and the iptables rules are removed. --- ## Requirements | Requirement | Notes | |---|---| | Python 3.11+ | Uses `asyncio`, `struct`, `socket`, `pathlib`, stdlib only | | Linux | `SO_ORIGINAL_DST` and `iptables` are Linux-specific | | Root | Required for iptables management; `--no-iptables` bypasses this | | iptables | Must be installed (`apt install iptables` or equivalent) | No PyPI packages are needed at runtime. --- ## Installation **Run directly from the source tree (recommended for a lab machine):** ```bash git clone cd portspoof_py sudo python3 -m portspoof_py -s tools/portspoof_signatures ``` **Install as a package:** ```bash pip install . # installs the portspoof-py entry point portspoof-py --help ``` **Install build tool only if needed:** ```bash pip install flit flit build # produces a wheel in dist/ ``` --- ## Quick start ### Test mode (no iptables, no root) Useful for verifying the installation or developing. ```bash python3 -m portspoof_py \ --no-iptables \ -p 4444 \ -s tools/portspoof_signatures \ -v # In another terminal — should receive a random service banner nc localhost 4444 ``` ### Full honeypot mode (as root) ```bash sudo python3 -m portspoof_py \ -p 4444 \ -s /etc/portspoof/portspoof_signatures \ -c /etc/portspoof/portspoof.conf \ -l /var/log/portspoof/portspoof.jsonl ``` After startup you can verify with: ```bash sudo iptables -t nat -L PREROUTING --line-numbers # Should show three portspoof rules nmap -p 1-1000 # All ports should appear open ``` Stop with Ctrl-C or `kill `. Rules are removed automatically. ### With the web admin interface ```bash # Create a credentials file first echo "admin:changeme" > admin.passwd sudo python3 -m portspoof_py \ -p 4444 \ -s /etc/portspoof/portspoof_signatures \ -l /var/log/portspoof/portspoof.jsonl \ --admin-port 8080 # Open http://127.0.0.1:8080 in a browser (HTTP Basic Auth prompt will appear) ``` --- ## CLI reference ``` python3 -m portspoof_py [OPTIONS] portspoof-py [OPTIONS] ``` ### Listener | Flag | Default | Description | |---|---|---| | `-p PORT` / `--port PORT` | `4444` | TCP port the honeypot listens on. This port is excluded from the iptables REDIRECT rule so it does not redirect to itself. | | `-i IP` / `--bind-ip IP` | all interfaces | Bind the listener to a specific IP address. | ### Signatures and configuration | Flag | Default | Description | |---|---|---| | `-s FILE` / `--signatures FILE` | — | Path to the signature file. Each line is a regex-like pattern used to generate a banner. At startup, one pattern is chosen randomly for each of the 65,536 ports. Without this flag every port returns an empty banner (the port appears open but silent). | | `-c FILE` / `--config FILE` | — | Path to the config file. Overrides specific ports or port ranges with a fixed payload after the signature map is built. | ### Logging | Flag | Default | Description | |---|---|---| | `-l FILE` / `--log-file FILE` | — | Append JSON connection events to this file. One line per connection. | | `-v` / `--verbose` | off | Print each connection event as JSON to stdout. | ### iptables | Flag | Default | Description | |---|---|---| | `--iface IFACE` | all interfaces | Restrict the iptables REDIRECT rule to a specific network interface (e.g. `eth0`). Useful when one interface is internal and should not be intercepted. | | `--no-iptables` | off | Skip iptables setup and teardown entirely. The listener still runs on `-p PORT`. Use for local testing or when you manage iptables externally. | ### Web admin | Flag | Default | Description | |---|---|---| | `--admin-port PORT` | disabled | Start the web admin interface on this port. | | `--admin-host HOST` | `127.0.0.1` | Address the admin interface binds to. Set to `0.0.0.0` to expose it on all interfaces (protect with a firewall). | | `--admin-passwd FILE` | `admin.passwd` | File containing `username:password` on a single line. Required when `--admin-port` is used. | | `--admin-ssl` | off | Serve the admin interface over HTTPS. | | `--admin-ssl-cert FILE` | `admin.crt` | TLS certificate PEM file. If the file does not exist it is auto-generated as a self-signed cert (requires `openssl` on PATH). | | `--admin-ssl-key FILE` | `admin.key` | TLS private key PEM file. Auto-generated alongside the cert if missing. | | `--email-config FILE` | `email.json` | JSON file where email alert settings are stored. Created automatically when you first save settings from the admin UI. | --- ## Signature file The signature file contains one pattern per line. Blank lines are ignored. The bundled file `tools/portspoof_signatures` contains 8,962 patterns derived from the nmap service detection database. Each pattern is passed through a three-pass regex materialiser at startup to produce a concrete banner string: **Pass 1** — group and bracket expansion - `(...)` groups are stripped, their content kept. - `[abc]`, `[a-z]`, `[0-9]`, `[^\0]` etc. are expanded: a random character (or random-length sequence for `*`/`+` quantifiers) is chosen from the defined set. **Pass 2** — special sequence substitution - `\w` → a random lowercase letter `a`–`z` - `\d` → a random digit `0`–`9` - `.` → a random lowercase letter - `\n`, `\r`, `\t` → literal newline, carriage return, tab **Pass 3** — hex escape decoding - `\xNN` → the byte with hex value `NN` **Example patterns and what they can produce:** ``` # Input pattern → Example output SSH-2.0-OpenSSH_([\w._-]+)\r\n → SSH-2g0-OpenSSH_QpKz\r\n 220 ([-.\w]+) ESMTP\r\n → 220 mxprod ESMTP\r\n HTTP/1.\d \d\d\d [\w ]+\r\n → HTTP/1.4 200 OK\r\n \x80\x01\x00\x80 → (four literal bytes) AMServer → AMServer ``` The file is read with latin-1 encoding so binary-like content in patterns does not cause decode errors. --- ## Config file The config file lets you pin specific ports or port ranges to a fixed payload, overriding the random assignment from the signature file. It is applied after the signature map is built. ### Format Lines beginning with `#` are comments and are ignored. Each active line has the form: ``` PORT "payload" START-END "payload" ``` The payload is everything between the **first** and **last** double-quote on the line. It is processed through the same three-pass materialiser as signature patterns, so hex escapes and regex metacharacters work. ### Example ``` # Single port — port 22 returns a specific SSH banner 22 "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6\r\n" # Port range — ports 80 through 443 return an HTTP 200 80-443 "HTTP/1.1 200 OK\r\nServer: nginx/1.24.0\r\nContent-Length: 0\r\n\r\n" # Hex payload — raw bytes 9200 "\x7b\x22\x6e\x61\x6d\x65\x22\x3a\x22\x65\x6c\x61\x73\x74\x69\x63\x22\x7d" ``` The bundled `tools/portspoof.conf` shows the full comment syntax and more examples. --- ## iptables rules When running without `--no-iptables`, three rules are appended to the `nat` PREROUTING chain at startup and removed at shutdown. ### Rules added (example with `-p 4444 --admin-port 8080`) ``` iptables -t nat -A PREROUTING -p tcp --dport 22 -j RETURN iptables -t nat -A PREROUTING -p tcp --dport 8080 -j RETURN iptables -t nat -A PREROUTING -p tcp --dport 4444 -j RETURN iptables -t nat -A PREROUTING -p tcp -j REDIRECT --to-port 4444 ``` The exempt ports are always added **before** the REDIRECT rule in this order: 1. Port 22 (SSH) — always first, so you cannot lock yourself out. 2. `--admin-port` — exempted automatically if specified, so the admin interface remains reachable. 3. The listener port (`-p`) — prevents a redirect loop. Without `--admin-port`, the middle rule is omitted. With `--iface eth0`, each rule gains `--in-interface eth0`. ### Verifying the rules ```bash sudo iptables -t nat -L PREROUTING -v --line-numbers ``` ### Manual cleanup If the process is killed with SIGKILL (skipping the shutdown handler), the systemd `ExecStopPost` directives remove the rules. You can also remove them manually: ```bash sudo iptables -t nat -D PREROUTING -p tcp --dport 22 -j RETURN sudo iptables -t nat -D PREROUTING -p tcp --dport 4444 -j RETURN sudo iptables -t nat -D PREROUTING -p tcp -j REDIRECT --to-port 4444 ``` ### Persistence across reboots iptables rules are not persistent by default. The systemd service re-adds them on start. If you want them to survive without the service running, use `iptables-save` / `iptables-restore` or `netfilter-persistent`. --- ## JSON log With `-l FILE`, each accepted connection appends one JSON object followed by a newline to the log file. ### Fields | Field | Type | Description | |---|---|---| | `timestamp` | string | ISO 8601 UTC timestamp with microseconds | | `src_ip` | string | Source IP address of the scanner | | `src_port` | integer | Source (ephemeral) port of the scanner | | `dst_port` | integer | Port the scanner originally connected to (recovered via `SO_ORIGINAL_DST`) | | `banner_hex` | string | Hex-encoded bytes of the banner that was sent | | `banner_len` | integer | Length of the banner in bytes | ### Example ```json {"timestamp": "2025-06-01T14:32:07.841203+00:00", "src_ip": "198.51.100.42", "src_port": 54312, "dst_port": 443, "banner_hex": "485454502f312e31203230300d0a", "banner_len": 14} ``` ### Querying the log ```bash # Show all source IPs sorted by frequency jq -r .src_ip portspoof.jsonl | sort | uniq -c | sort -rn | head -20 # Show all targeted ports sorted by frequency jq -r .dst_port portspoof.jsonl | sort | uniq -c | sort -rn | head -20 # Show connections from a specific IP jq 'select(.src_ip == "198.51.100.42")' portspoof.jsonl # Decode a banner from hex jq -r .banner_hex portspoof.jsonl | head -1 | xxd -r -p ``` --- ## Web admin interface Enable with `--admin-port PORT`. The interface binds to `127.0.0.1` by default; use `--admin-host 0.0.0.0` to expose it externally (ensure it is firewalled). ### Authentication Every request requires HTTP Basic Auth. Create a credentials file before starting: ```bash echo "admin:changeme" > admin.passwd ``` The file must contain a single line in `username:password` format. Pass a custom path with `--admin-passwd FILE`. ### HTTPS / TLS Add `--admin-ssl` to serve the interface over HTTPS: ```bash sudo python3 -m portspoof_py \ -p 4444 -s tools/portspoof_signatures \ --admin-port 8080 --admin-ssl ``` On first run, a self-signed certificate (`admin.crt` / `admin.key`) is generated automatically using `openssl`. Your browser will show an untrusted-certificate warning — add an exception or use a real cert. To use your own certificate: ```bash --admin-ssl --admin-ssl-cert /etc/ssl/mycert.pem --admin-ssl-key /etc/ssl/mykey.pem ``` ### Dashboard — `GET /` A dark-themed HTML page that auto-refreshes every 5 seconds. Sections: - **Stat cards** — total connections, connections in the last 60 seconds, uptime, ports mapped, and last connection time. - **Top source IPs** — the 10 most active scanner addresses since startup. - **Top targeted ports** — the 10 most-probed ports since startup. - **Banner lookup** — enter any port number (0–65535) and see the hex and text preview of the banner that port will send. The result persists across auto-refreshes when using the `?port=N` query parameter. - **Recent connections** — the 50 most-recent connections, newest first, with timestamp, source, destination port, banner hex excerpt, and banner length. ### Banner lookup shortcut Append `?port=N` to the dashboard URL to pre-populate the lookup: ``` http://127.0.0.1:8080/?port=443 ``` ### JSON API All endpoints require Basic Auth and return `Content-Type: application/json`. #### `GET /api/stats` Current statistics snapshot. ```json { "uptime": "2h 14m 37s", "total_connections": 18432, "last_connection": "2025-06-01T14:32:07.841203+00:00", "connections_per_min": 312, "ports_mapped": 65536, "top_ips": [ {"ip": "198.51.100.42", "count": 9821}, {"ip": "203.0.113.7", "count": 4102} ], "top_ports": [ {"port": 80, "count": 3201}, {"port": 443, "count": 2987}, {"port": 22, "count": 2104} ] } ``` #### `GET /api/connections?limit=N` The N most-recent connection events, newest first. `limit` defaults to 50, maximum 500. ```json [ { "timestamp": "2025-06-01T14:32:07.841203+00:00", "src_ip": "198.51.100.42", "src_port": 54312, "dst_port": 443, "banner_hex": "485454502f312e31203230300d0a", "banner_len": 14 } ] ``` #### `GET /api/banner?port=N` The pre-computed banner for port N. ```json { "port": 443, "banner_hex": "485454502f312e31203230300d0a", "banner_len": 14, "banner_text": "HTTP/1.1 200\r\n" } ``` `banner_text` is the banner decoded as latin-1. Non-printable bytes will appear as replacement characters in some terminals. #### `GET /api/email/config` Current email alert configuration (password is masked). --- ## Email alerts portspoof can send email digest alerts when connections are detected. Configuration is done entirely through the admin UI — no restart required. ### Setup 1. Start with `--admin-port` (the email config page is always available). 2. Open `http://127.0.0.1:8080/config` (or click **email alerts** in the dashboard header). 3. Fill in your SMTP details and click **Save**. 4. Click **Send test email** to verify delivery. ### How batching works Rather than sending one email per connection, portspoof collects events and sends a single digest: 1. When a matching connection arrives, it is added to a pending list and a flush timer starts. 2. Any further matching connections within the **batch delay** window are added to the same list. 3. After the batch delay elapses, one email is sent summarising all collected events. 4. The **cooldown** prevents a new batch from being sent until that many seconds have passed since the last one, avoiding email floods during sustained scans. ### Configuration options | Option | Default | Description | |---|---|---| | Enabled | off | Master switch — no emails are sent when disabled. | | SMTP host | — | Hostname of your SMTP server (e.g. `smtp.gmail.com`). | | SMTP port | `587` | Port to connect to. | | Use STARTTLS | on | Upgrade the connection with STARTTLS. Disable for plain SMTP or SSL-only servers. | | SMTP username | — | Login username for SMTP authentication. | | SMTP password | — | Login password. Stored in the config file; masked in the UI. | | From address | — | Sender address. Defaults to the SMTP username if blank. | | To address | — | Recipient address for alert emails. | | Trigger ports | (all) | Comma-separated list of ports that trigger alerts. Leave blank to alert on every port. | | Batch delay | `60` s | How long to wait and collect events before sending one digest email. | | Cooldown | `300` s | Minimum time between batch emails. | ### Config file Settings are saved to `email.json` (or the path given by `--email-config`) as JSON. The file is created on first save. You can edit it directly; changes take effect at the next batch send. ```json { "enabled": true, "smtp_host": "smtp.gmail.com", "smtp_port": 587, "smtp_starttls": true, "smtp_user": "you@gmail.com", "smtp_password": "app-password-here", "from_addr": "", "to_addr": "alerts@example.com", "trigger_ports": [22, 3389], "batch_delay_seconds": 60, "cooldown_seconds": 300 } ``` ### Example digest email ``` Subject: [portspoof] 3 connections detected portspoof alert — 3 connections 2025-06-01 14:32:07 198.51.100.42:54312 → port 22 (14 B 485353482d322e302d4f70656e5353…) 2025-06-01 14:32:09 198.51.100.42:54398 → port 23 (6 B deadbeef0102) 2025-06-01 14:32:11 203.0.113.7:61204 → port 3389 (0 B ) ``` --- ## systemd service The included `portspoof.service` file runs portspoof as a system service that starts after the network is up, restarts on failure, and removes iptables rules even if it is killed with SIGKILL. ### Installation ```bash # Copy files sudo cp portspoof.service /etc/systemd/system/ sudo mkdir -p /etc/portspoof /var/log/portspoof sudo cp tools/portspoof_signatures /etc/portspoof/ sudo cp tools/portspoof.conf /etc/portspoof/ # Enable and start sudo systemctl daemon-reload sudo systemctl enable portspoof sudo systemctl start portspoof # Check status sudo systemctl status portspoof sudo journalctl -u portspoof -f ``` ### Adding the admin interface to the service ```bash # Create credentials file echo "admin:changeme" | sudo tee /etc/portspoof/admin.passwd sudo chmod 600 /etc/portspoof/admin.passwd ``` Edit `/etc/systemd/system/portspoof.service` and append the flags to `ExecStart`: ```ini ExecStart=/usr/bin/python3 -m portspoof_py \ -s /etc/portspoof/portspoof_signatures \ -c /etc/portspoof/portspoof.conf \ -l /var/log/portspoof/portspoof.jsonl \ --admin-port 8080 \ --admin-passwd /etc/portspoof/admin.passwd \ --email-config /etc/portspoof/email.json ``` Then reload: ```bash sudo systemctl daemon-reload sudo systemctl restart portspoof ``` ### Stopping ```bash sudo systemctl stop portspoof # iptables rules are removed during graceful shutdown # ExecStopPost directives handle the SIGKILL case ``` --- ## Recipes ### Honeypot on a dedicated interface only ```bash sudo python3 -m portspoof_py \ -p 4444 \ --iface eth1 \ -s /etc/portspoof/portspoof_signatures \ -l /var/log/portspoof/portspoof.jsonl ``` Only traffic arriving on `eth1` is redirected. Traffic on other interfaces (e.g. a management interface) is unaffected. ### Monitor in real time with the admin API ```bash # Poll stats every second watch -n1 'curl -su admin:changeme http://127.0.0.1:8080/api/stats | python3 -m json.tool' # Tail recent connections watch -n2 'curl -su admin:changeme "http://127.0.0.1:8080/api/connections?limit=10" | python3 -m json.tool' ``` ### Inspect what a specific port would send ```bash curl -su admin:changeme "http://127.0.0.1:8080/api/banner?port=22" | python3 -m json.tool # Decode banner bytes directly curl -su admin:changeme "http://127.0.0.1:8080/api/banner?port=22" \ | python3 -c "import sys,json; print(json.load(sys.stdin)['banner_text'])" ``` ### Custom banners for a port range Create `/etc/portspoof/portspoof.conf`: ``` # Make ports 8080-8090 look like nginx 8080-8090 "HTTP/1.1 200 OK\r\nServer: nginx/1.24.0\r\nContent-Length: 612\r\n\r\n" # Make port 3306 look like MySQL 3306 "\x4a\x00\x00\x00\x0a\x38\x2e\x30\x2e\x33\x36\x00" ``` ### Analyse a scan after the fact ```bash # Top 10 IPs jq -r .src_ip /var/log/portspoof/portspoof.jsonl \ | sort | uniq -c | sort -rn | head -10 # All ports targeted in the last hour jq -r 'select(.timestamp > "2025-06-01T13:00:00") | .dst_port' \ /var/log/portspoof/portspoof.jsonl | sort -n | uniq -c | sort -rn | head -20 # Check if a specific IP hit you grep '"src_ip": "198.51.100.42"' /var/log/portspoof/portspoof.jsonl | wc -l ``` --- ## Troubleshooting ### `ERROR: root required for iptables management` Run with `sudo`, or pass `--no-iptables` if you are testing locally. ### `ERROR: iptables setup failed` Check that iptables is installed and the `nat` table is available: ```bash sudo iptables -t nat -L ``` On some minimal systems you may need: ```bash sudo apt install iptables # Debian/Ubuntu sudo modprobe ip_tables sudo modprobe iptable_nat ``` ### Banners contain `000` instead of null bytes This is expected. The signature file uses `\0` to represent null bytes in the nmap probe format, but the banner materialiser does not interpret `\0` as a null byte — it outputs the ASCII character `0` (0x30). Use `\x00` in signature patterns or config payloads when you need an actual null byte. ### Port 22 is redirected and SSH breaks The iptables setup adds a `RETURN` rule for port 22 before the `REDIRECT` rule, so SSH should always be exempt. If you are still locked out, verify the rule order: ```bash sudo iptables -t nat -L PREROUTING --line-numbers ``` The `RETURN` rules for port 22 and the listener port must appear **before** the `REDIRECT` rule. If they are in the wrong order, remove all portspoof rules and restart the service. ### `SO_ORIGINAL_DST` returns the listener port instead of the original port This happens when there is no `REDIRECT` rule in effect (e.g. `--no-iptables` mode, or the rule is missing). The server falls back to reporting the listener port as the destination. Banners are still sent; the logged `dst_port` will be the listener port rather than the port the scanner connected to. ### Admin interface is not accessible from a remote machine By default `--admin-host` is `127.0.0.1` (localhost only). To expose it remotely: ```bash --admin-host 0.0.0.0 --admin-port 8080 ``` Then allow the port in your firewall and restrict access to trusted IPs: ```bash sudo iptables -A INPUT -p tcp --dport 8080 -s -j ACCEPT sudo iptables -A INPUT -p tcp --dport 8080 -j DROP ``` ### Startup is slow (~4–5 seconds) The banner map is built synchronously at startup: 65,536 ports × one signature materialisation each. This is expected and is a one-time cost. After startup, every connection is served from the pre-computed map with no per-connection processing.