24 KiB
portspoof-py
A Python asyncio rewrite of 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
- How it works
- Requirements
- Installation
- Quick start
- CLI reference
- Signature file
- Config file
- iptables rules
- JSON log
- Web admin interface
- Email alerts
- systemd service
- Recipes
- 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 ─────────────────►│──────────────────────────►│
- iptables PREROUTING REDIRECT — on startup, three rules are added to the
nattable. All incoming TCP traffic (except port 22 and the listener port itself) is transparently redirected to the single listener port. - Single asyncio listener — one coroutine handles every connection. There is no thread pool and no polling loop.
- 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). - 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.
- 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):
git clone <repo>
cd portspoof_py
sudo python3 -m portspoof_py -s tools/portspoof_signatures
Install as a package:
pip install . # installs the portspoof-py entry point
portspoof-py --help
Install build tool only if needed:
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.
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)
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:
sudo iptables -t nat -L PREROUTING --line-numbers
# Should show three portspoof rules
nmap -p 1-1000 <your-ip>
# All ports should appear open
Stop with Ctrl-C or kill <pid>. Rules are removed automatically.
With the web admin interface
# 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 lettera–z\d→ a random digit0–9.→ a random lowercase letter\n,\r,\t→ literal newline, carriage return, tab
Pass 3 — hex escape decoding
\xNN→ the byte with hex valueNN
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:
- Port 22 (SSH) — always first, so you cannot lock yourself out.
--admin-port— exempted automatically if specified, so the admin interface remains reachable.- 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
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:
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
{"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
# 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:
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:
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:
--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, each annotated with a two-letter country code fetched from the geo lookup service.
- 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=Nquery parameter. - Recent connections — the 50 most-recent connections, newest first, with timestamp, source IP (with country code), destination port, and banner length.
IP geolocation
Country codes are resolved asynchronously on each dashboard render using https://www.daprogs.com/ip/?raw=1&ip=<IP>. Results are cached in memory for the lifetime of the process, so each IP is only looked up once. Private or unroutable addresses (e.g. 127.0.0.1, 192.168.x.x) that return no result are displayed without a country code — no crash, no placeholder.
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.
{
"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 100, maximum 500.
[
{
"timestamp": "2025-06-01T14:32:07.841203+00:00",
"src_ip": "198.51.100.42",
"src_port": 54312,
"dst_port": 443,
"banner_len": 14
}
]
GET /api/banner?port=N
The pre-computed banner for port N.
{
"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
- Start with
--admin-port(the email config page is always available). - Open
http://127.0.0.1:8080/config(or click email alerts in the dashboard header). - Fill in your SMTP details and click Save.
- 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:
- When a matching connection arrives, it is added to a pending list and a flush timer starts.
- Any further matching connections within the batch delay window are added to the same list.
- After the batch delay elapses, one email is sent summarising all collected events.
- 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.
{
"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
# 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
# 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:
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:
sudo systemctl daemon-reload
sudo systemctl restart portspoof
Stopping
sudo systemctl stop portspoof
# iptables rules are removed during graceful shutdown
# ExecStopPost directives handle the SIGKILL case
Recipes
Honeypot on a dedicated interface only
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
# Poll stats every second
watch -n1 'curl -su admin:changeme http://127.0.0.1:8080/api/stats | python3 -m json.tool'
# Tail the 10 most 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
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
# 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:
sudo iptables -t nat -L
On some minimal systems you may need:
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:
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:
--admin-host 0.0.0.0 --admin-port 8080
Then allow the port in your firewall and restrict access to trusted IPs:
sudo iptables -A INPUT -p tcp --dport 8080 -s <your-ip> -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.